cocoindex-code 0.2.33__tar.gz → 0.2.35__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 (24) hide show
  1. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/.gitignore +3 -0
  2. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/PKG-INFO +35 -6
  3. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/README.md +30 -1
  4. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/pyproject.toml +5 -5
  5. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/__init__.py +6 -0
  6. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/_version.py +2 -2
  7. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/cli.py +141 -45
  8. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/client.py +32 -15
  9. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/daemon.py +7 -1
  10. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/protocol.py +6 -0
  11. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/shared.py +6 -2
  12. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/LICENSE +0 -0
  13. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/__main__.py +0 -0
  14. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/_daemon_paths.py +0 -0
  15. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/chunking.py +0 -0
  16. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/embedder_defaults.py +0 -0
  17. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/embedder_params.py +0 -0
  18. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/indexer.py +0 -0
  19. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/litellm_embedder.py +0 -0
  20. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/project.py +0 -0
  21. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/query.py +0 -0
  22. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/schema.py +0 -0
  23. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/server.py +0 -0
  24. {cocoindex_code-0.2.33 → cocoindex_code-0.2.35}/src/cocoindex_code/settings.py +0 -0
@@ -33,6 +33,9 @@ ENV/
33
33
  *.swp
34
34
  *.swo
35
35
 
36
+ # Claude Code local config and state
37
+ /.claude/
38
+
36
39
  # Testing
37
40
  .tox/
38
41
  .coverage
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoindex-code
3
- Version: 0.2.33
3
+ Version: 0.2.35
4
4
  Summary: MCP server for indexing and querying codebases using CocoIndex
5
5
  Project-URL: Homepage, https://github.com/cocoindex-io/cocoindex-code
6
6
  Project-URL: Repository, https://github.com/cocoindex-io/cocoindex-code
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.11
20
- Requires-Dist: cocoindex[litellm]<1.1.0,>=1.0.0
20
+ Requires-Dist: cocoindex[litellm]<1.1.0,>=1.0.6
21
21
  Requires-Dist: einops>=0.8.2
22
22
  Requires-Dist: mcp>=1.0.0
23
23
  Requires-Dist: msgspec>=0.19.0
@@ -29,7 +29,7 @@ Requires-Dist: questionary>=2.0.0
29
29
  Requires-Dist: sqlite-vec>=0.1.0
30
30
  Requires-Dist: typer>=0.9.0
31
31
  Provides-Extra: dev
32
- Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.0; extra == 'dev'
32
+ Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.6; extra == 'dev'
33
33
  Requires-Dist: mypy>=1.0.0; extra == 'dev'
34
34
  Requires-Dist: prek>=0.1.0; extra == 'dev'
35
35
  Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
@@ -37,9 +37,9 @@ Requires-Dist: pytest-cov>=4.0.0; extra == 'dev'
37
37
  Requires-Dist: pytest>=7.0.0; extra == 'dev'
38
38
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
39
39
  Provides-Extra: embeddings-local
40
- Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.0; extra == 'embeddings-local'
40
+ Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.6; extra == 'embeddings-local'
41
41
  Provides-Extra: full
42
- Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.0; extra == 'full'
42
+ Requires-Dist: cocoindex[sentence-transformers]<1.1.0,>=1.0.6; extra == 'full'
43
43
  Description-Content-Type: text/markdown
44
44
 
45
45
  <p align="center">
@@ -568,6 +568,23 @@ envs:
568
568
 
569
569
  </details>
570
570
 
571
+ <details>
572
+ <summary>OpenAI-compatible (custom endpoint)</summary>
573
+
574
+ Many providers (vLLM, LM Studio, LocalAI, Together, Fireworks, DeepInfra, …) expose an OpenAI-compatible embedding API. Use the `openai/` prefix and point `OPENAI_BASE_URL` at your endpoint:
575
+
576
+ ```yaml
577
+ embedding:
578
+ model: openai/your-model-name
579
+ envs:
580
+ OPENAI_BASE_URL: https://your-endpoint/v1
581
+ OPENAI_API_KEY: your-api-key
582
+ ```
583
+
584
+ Don't append `/embeddings` to the base URL — LiteLLM handles that.
585
+
586
+ </details>
587
+
571
588
  <details>
572
589
  <summary>Azure OpenAI</summary>
573
590
 
@@ -656,7 +673,7 @@ envs:
656
673
 
657
674
  </details>
658
675
 
659
- Any [LiteLLM-supported model](https://docs.litellm.ai/docs/embedding/supported_embedding) works. When using a LiteLLM model, set `provider: litellm` (or omit `provider` — LiteLLM is the default for non-`sentence-transformers` models).
676
+ Any [LiteLLM-supported model](https://docs.litellm.ai/docs/embedding/supported_embedding) works. When using a LiteLLM model, set `provider: litellm` (or omit `provider` — LiteLLM is the default for non-`sentence-transformers` models). For the full list of env vars each provider reads (API keys, base URLs, regions, …), see LiteLLM's [Setting API Keys](https://docs.litellm.ai/docs/set_keys).
660
677
 
661
678
  ### Local SentenceTransformers Models
662
679
 
@@ -776,6 +793,18 @@ If you previously configured `cocoindex-code` via environment variables, the `co
776
793
  | `COCOINDEX_CODE_EXCLUDED_PATTERNS` | `exclude_patterns` in project `settings.yml` |
777
794
  | `COCOINDEX_CODE_EXTRA_EXTENSIONS` | `include_patterns` + `language_overrides` in project `settings.yml` |
778
795
 
796
+ ## Telemetry
797
+
798
+ `cocoindex-code` sends anonymous usage telemetry through CocoIndex so we can see how the tool is used in aggregate and prioritize improvements. The events identify themselves as `application: cocoindex-code`.
799
+
800
+ We **do not** collect your source code, file paths, queries, search results, embeddings, settings, or any other content from your codebase or environment.
801
+
802
+ To opt out, set:
803
+
804
+ ```bash
805
+ export COCOINDEX_DISABLE_USAGE_TRACKING=1
806
+ ```
807
+
779
808
  ## Large codebase / Enterprise
780
809
  [CocoIndex](https://github.com/cocoindex-io/cocoindex) is an ultra efficient indexing engine that also works on large codebases at scale for enterprises. In enterprise scenarios it is a lot more efficient to share indexes with teammates when there are large or many repos. We also have advanced features like branch dedupe etc designed for enterprise users.
781
810
 
@@ -524,6 +524,23 @@ envs:
524
524
 
525
525
  </details>
526
526
 
527
+ <details>
528
+ <summary>OpenAI-compatible (custom endpoint)</summary>
529
+
530
+ Many providers (vLLM, LM Studio, LocalAI, Together, Fireworks, DeepInfra, …) expose an OpenAI-compatible embedding API. Use the `openai/` prefix and point `OPENAI_BASE_URL` at your endpoint:
531
+
532
+ ```yaml
533
+ embedding:
534
+ model: openai/your-model-name
535
+ envs:
536
+ OPENAI_BASE_URL: https://your-endpoint/v1
537
+ OPENAI_API_KEY: your-api-key
538
+ ```
539
+
540
+ Don't append `/embeddings` to the base URL — LiteLLM handles that.
541
+
542
+ </details>
543
+
527
544
  <details>
528
545
  <summary>Azure OpenAI</summary>
529
546
 
@@ -612,7 +629,7 @@ envs:
612
629
 
613
630
  </details>
614
631
 
615
- Any [LiteLLM-supported model](https://docs.litellm.ai/docs/embedding/supported_embedding) works. When using a LiteLLM model, set `provider: litellm` (or omit `provider` — LiteLLM is the default for non-`sentence-transformers` models).
632
+ Any [LiteLLM-supported model](https://docs.litellm.ai/docs/embedding/supported_embedding) works. When using a LiteLLM model, set `provider: litellm` (or omit `provider` — LiteLLM is the default for non-`sentence-transformers` models). For the full list of env vars each provider reads (API keys, base URLs, regions, …), see LiteLLM's [Setting API Keys](https://docs.litellm.ai/docs/set_keys).
616
633
 
617
634
  ### Local SentenceTransformers Models
618
635
 
@@ -732,6 +749,18 @@ If you previously configured `cocoindex-code` via environment variables, the `co
732
749
  | `COCOINDEX_CODE_EXCLUDED_PATTERNS` | `exclude_patterns` in project `settings.yml` |
733
750
  | `COCOINDEX_CODE_EXTRA_EXTENSIONS` | `include_patterns` + `language_overrides` in project `settings.yml` |
734
751
 
752
+ ## Telemetry
753
+
754
+ `cocoindex-code` sends anonymous usage telemetry through CocoIndex so we can see how the tool is used in aggregate and prioritize improvements. The events identify themselves as `application: cocoindex-code`.
755
+
756
+ We **do not** collect your source code, file paths, queries, search results, embeddings, settings, or any other content from your codebase or environment.
757
+
758
+ To opt out, set:
759
+
760
+ ```bash
761
+ export COCOINDEX_DISABLE_USAGE_TRACKING=1
762
+ ```
763
+
735
764
  ## Large codebase / Enterprise
736
765
  [CocoIndex](https://github.com/cocoindex-io/cocoindex) is an ultra efficient indexing engine that also works on large codebases at scale for enterprises. In enterprise scenarios it is a lot more efficient to share indexes with teammates when there are large or many repos. We also have advanced features like branch dedupe etc designed for enterprise users.
737
766
 
@@ -23,7 +23,7 @@ classifiers = [
23
23
 
24
24
  dependencies = [
25
25
  "mcp>=1.0.0",
26
- "cocoindex[litellm]>=1.0.0,<1.1.0",
26
+ "cocoindex[litellm]>=1.0.6,<1.1.0",
27
27
  "sqlite-vec>=0.1.0",
28
28
  "pydantic>=2.0.0",
29
29
  "numpy>=1.24.0",
@@ -39,7 +39,7 @@ dependencies = [
39
39
  # `embeddings-local` is the primary feature extra: it pulls in
40
40
  # `sentence-transformers` (via cocoindex) so local embeddings work without
41
41
  # an API key.
42
- embeddings-local = ["cocoindex[sentence-transformers]>=1.0.0,<1.1.0"]
42
+ embeddings-local = ["cocoindex[sentence-transformers]>=1.0.6,<1.1.0"]
43
43
  # `full` is the umbrella "batteries-included" alias. Today it's just
44
44
  # `embeddings-local`, but we expect to bundle more optional niceties under
45
45
  # it over time — users who want everything can keep using `[full]` and pick
@@ -47,7 +47,7 @@ embeddings-local = ["cocoindex[sentence-transformers]>=1.0.0,<1.1.0"]
47
47
  # `:full` image variant for consistency across install paths. Contents are
48
48
  # inlined rather than self-referencing `cocoindex-code[embeddings-local]`
49
49
  # to avoid resolver edge cases with older pip.
50
- full = ["cocoindex[sentence-transformers]>=1.0.0,<1.1.0"]
50
+ full = ["cocoindex[sentence-transformers]>=1.0.6,<1.1.0"]
51
51
  dev = [
52
52
  "pytest>=7.0.0",
53
53
  "pytest-asyncio>=0.21.0",
@@ -55,7 +55,7 @@ dev = [
55
55
  "ruff>=0.1.0",
56
56
  "mypy>=1.0.0",
57
57
  "prek>=0.1.0",
58
- "cocoindex[sentence-transformers]>=1.0.0,<1.1.0",
58
+ "cocoindex[sentence-transformers]>=1.0.6,<1.1.0",
59
59
  ]
60
60
 
61
61
  [project.scripts]
@@ -89,7 +89,7 @@ dev = [
89
89
  "mypy>=1.0.0",
90
90
  "prek>=0.1.0",
91
91
  "types-pyyaml>=6.0.12.20250915",
92
- "cocoindex[sentence-transformers]>=1.0.3,<1.1.0",
92
+ "cocoindex[sentence-transformers]>=1.0.7,<1.1.0",
93
93
  ]
94
94
 
95
95
  [tool.ruff]
@@ -3,8 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import os
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
9
+ # Identify this application in cocoindex's telemetry payloads. Must be set
10
+ # before any `import cocoindex` runs (the value is read once at telemetry
11
+ # init time). See cocoindex-io/cocoindex#1992.
12
+ os.environ.setdefault("COCOINDEX_APPLICATION_FOR_TRACKING", "cocoindex-code")
13
+
8
14
  logging.basicConfig(level=logging.WARNING)
9
15
 
10
16
  from ._version import __version__ # noqa: E402
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.33'
22
- __version_tuple__ = version_tuple = (0, 2, 33)
21
+ __version__ = version = '0.2.35'
22
+ __version_tuple__ = version_tuple = (0, 2, 35)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -321,12 +321,35 @@ def remove_from_gitignore(project_root: Path) -> None:
321
321
  _LITELLM_MODELS_URL = "https://docs.litellm.ai/docs/embedding/supported_embedding"
322
322
 
323
323
 
324
+ def _st_model_rejection_reason(model: str) -> str | None:
325
+ """Why ``model`` can't be a sentence-transformers model, or None if it's fine.
326
+
327
+ sentence-transformers loads HuggingFace model ids. An ``ollama/`` prefix is a
328
+ LiteLLM/Ollama route that ST tries (and fails) to resolve as a HuggingFace
329
+ repo — the user wants the litellm provider instead (issue #181). Real
330
+ HuggingFace ids that contain an ``org/`` slash (``Snowflake/...``,
331
+ ``openai/...``) are left alone.
332
+ """
333
+ if model.strip().lower().startswith("ollama/"):
334
+ return (
335
+ "ollama/… models run via litellm, not sentence-transformers — "
336
+ "go back and pick the litellm provider instead."
337
+ )
338
+ return None
339
+
340
+
324
341
  def _resolve_embedding_choice(
325
342
  litellm_model_flag: str | None,
326
343
  st_installed: bool,
327
344
  tty: bool,
345
+ previous: EmbeddingSettings | None = None,
328
346
  ) -> EmbeddingSettings:
329
- """Resolve the embedding settings per the init control-flow diagram."""
347
+ """Resolve the embedding settings per the init control-flow diagram.
348
+
349
+ On a retry, ``previous`` holds the choice from the last attempt; its
350
+ provider and model become the prompt defaults so the user only edits
351
+ what was wrong instead of retyping everything.
352
+ """
330
353
  if litellm_model_flag is not None:
331
354
  return EmbeddingSettings(provider="litellm", model=litellm_model_flag)
332
355
 
@@ -349,14 +372,15 @@ def _resolve_embedding_choice(
349
372
  "Embedding provider",
350
373
  choices=[
351
374
  questionary.Choice(
352
- title="sentence-transformers (local, free)",
375
+ title="sentence-transformers (local, free — built-in HuggingFace models)",
353
376
  value="sentence-transformers",
354
377
  ),
355
378
  questionary.Choice(
356
- title="litellm (cloud, 100+ providers)",
379
+ title="litellm (100+ providers — cloud APIs & local Ollama)",
357
380
  value="litellm",
358
381
  ),
359
382
  ],
383
+ default=previous.provider if previous is not None else None,
360
384
  ).ask()
361
385
  else:
362
386
  _typer.echo(
@@ -369,10 +393,16 @@ def _resolve_embedding_choice(
369
393
  raise _typer.Exit(code=1)
370
394
 
371
395
  if provider == "sentence-transformers":
372
- model = questionary.text("Model name", default=DEFAULT_ST_MODEL).ask()
396
+ default_model = previous.model if previous is not None else DEFAULT_ST_MODEL
397
+ model = questionary.text(
398
+ "Model name",
399
+ default=default_model,
400
+ validate=lambda m: _st_model_rejection_reason(m) or True,
401
+ ).ask()
373
402
  elif provider == "litellm":
374
403
  _typer.echo(f"See supported LiteLLM embedding models: {_LITELLM_MODELS_URL}")
375
- model = questionary.text("Model name").ask()
404
+ default_model = previous.model if previous is not None else ""
405
+ model = questionary.text("Model name", default=default_model).ask()
376
406
  else:
377
407
  _typer.echo(f"Error: unknown provider {provider!r}", err=True)
378
408
  raise _typer.Exit(code=1)
@@ -392,13 +422,14 @@ def _ok_fail_tag(ok: bool) -> str:
392
422
  return _click.style("[FAIL]", fg="red", bold=True)
393
423
 
394
424
 
395
- def _run_init_model_check(settings_path: Path) -> None:
396
- """Ask the daemon to test the embedding model; print results and a hint on failure.
425
+ def _run_init_model_check() -> bool:
426
+ """Ask the daemon to test the embedding model; print results. Return True if all pass.
397
427
 
398
428
  Drives the check via `DoctorRequest(project_root=None)`. The daemon loads
399
429
  the model once and stays running, so the user's next `ccc index` starts
400
430
  warm. Both DaemonStartError and generic exceptions are rendered as a
401
- synthetic failed DoctorCheckResult — uniform failure-output shape.
431
+ synthetic failed DoctorCheckResult — uniform failure-output shape. The
432
+ caller decides what to show on failure (retry prompt / next-steps block).
402
433
  """
403
434
  from rich.console import Console as _Console
404
435
  from rich.live import Live as _Live
@@ -426,55 +457,101 @@ def _run_init_model_check(settings_path: Path) -> None:
426
457
  )
427
458
  ]
428
459
 
429
- failed = False
460
+ ok = True
430
461
  for r in results:
431
462
  if r.name == "done":
432
463
  continue
433
- _print_doctor_result(r)
464
+ _print_doctor_result(r, verbose=False)
434
465
  if not r.ok:
435
- failed = True
466
+ ok = False
467
+ return ok
436
468
 
437
- if failed:
438
- display_path = format_path_for_display(settings_path)
439
- _typer.echo(
440
- f"You can edit {display_path} to change the model or add API keys\n"
441
- "under `envs:`. Then run `ccc doctor` to verify.",
442
- err=True,
443
- )
469
+
470
+ def _print_init_next_steps(settings_path: Path) -> None:
471
+ """Prominent recovery block shown after a failed init model check."""
472
+ import click as _click
473
+
474
+ display_path = format_path_for_display(settings_path)
475
+ _typer.echo(err=True)
476
+ _typer.echo(_click.style(" Next steps", bold=True), err=True)
477
+ _typer.echo(_click.style(f" {'─' * 38}", fg="bright_black"), err=True)
478
+ _typer.echo(
479
+ f" 1. Edit {_click.style(display_path, fg='cyan', bold=True)}\n"
480
+ " to change the model or add API keys under `envs:`.",
481
+ err=True,
482
+ )
483
+ _typer.echo(" 2. Run `ccc doctor` to verify.", err=True)
484
+ _typer.echo() # trailing blank before whatever init prints next
444
485
 
445
486
 
446
487
  def _setup_user_settings_interactive(litellm_model_flag: str | None) -> None:
447
- """Interactive global-settings setup — only runs when settings are missing."""
488
+ """Interactive global-settings setup — only runs when settings are missing.
489
+
490
+ Loops until the configured model passes its check or the user chooses to
491
+ keep the current settings. On failure we offer a retry, but only when we
492
+ can actually re-prompt for a different model — i.e. interactive and not
493
+ pinned by ``--litellm-model``; otherwise we just print the next steps.
494
+ """
448
495
  from .embedder_defaults import lookup_defaults
449
496
  from .shared import is_sentence_transformers_installed
450
497
 
451
- embedding = _resolve_embedding_choice(
452
- litellm_model_flag=litellm_model_flag,
453
- st_installed=is_sentence_transformers_installed(),
454
- tty=sys.stdin.isatty(),
455
- )
498
+ st_installed = is_sentence_transformers_installed()
499
+ interactive = sys.stdin.isatty()
500
+ previous: EmbeddingSettings | None = None
456
501
 
457
- # Apply curated defaults if the model is in our table.
458
- indexing_defaults, query_defaults = lookup_defaults(embedding.provider, embedding.model)
459
- defaults_applied = indexing_defaults is not None or query_defaults is not None
460
- if defaults_applied:
461
- embedding.indexing_params = indexing_defaults or {}
462
- embedding.query_params = query_defaults or {}
502
+ while True:
503
+ embedding = _resolve_embedding_choice(
504
+ litellm_model_flag=litellm_model_flag,
505
+ st_installed=st_installed,
506
+ tty=interactive,
507
+ previous=previous,
508
+ )
509
+ previous = embedding # remembered as the defaults for a potential retry
463
510
 
464
- path = save_initial_user_settings(embedding, defaults_applied=defaults_applied)
465
- _typer.echo()
466
- _typer.echo(f"Created user settings: {format_path_for_display(path)}")
511
+ # Apply curated defaults if the model is in our table.
512
+ indexing_defaults, query_defaults = lookup_defaults(embedding.provider, embedding.model)
513
+ defaults_applied = indexing_defaults is not None or query_defaults is not None
514
+ if defaults_applied:
515
+ embedding.indexing_params = indexing_defaults or {}
516
+ embedding.query_params = query_defaults or {}
467
517
 
468
- if defaults_applied:
518
+ path = save_initial_user_settings(embedding, defaults_applied=defaults_applied)
469
519
  _typer.echo()
470
- _typer.echo(f"Applied recommended defaults for {embedding.model}:")
471
- _typer.echo(f" indexing_params: {embedding.indexing_params}")
472
- _typer.echo(f" query_params: {embedding.query_params}")
520
+ _typer.echo(f"Created user settings: {format_path_for_display(path)}")
473
521
 
474
- _typer.echo()
475
- _typer.echo(f"Testing embedding model: {embedding.provider} / {embedding.model}")
476
- _run_init_model_check(path)
477
- _typer.echo()
522
+ if defaults_applied:
523
+ _typer.echo()
524
+ _typer.echo(f"Applied recommended defaults for {embedding.model}:")
525
+ _typer.echo(f" indexing_params: {embedding.indexing_params}")
526
+ _typer.echo(f" query_params: {embedding.query_params}")
527
+
528
+ _typer.echo()
529
+ _typer.echo(f"Testing embedding model: {embedding.provider} / {embedding.model}")
530
+ if _run_init_model_check():
531
+ _typer.echo()
532
+ return
533
+
534
+ # Model check failed. Retry only makes sense if we can re-prompt.
535
+ if interactive and litellm_model_flag is None:
536
+ import questionary
537
+
538
+ _typer.echo() # separate the failure output from the prompt below
539
+ choice = questionary.select(
540
+ "The embedding model couldn't be loaded. What would you like to do?",
541
+ choices=[
542
+ questionary.Choice(title="Try a different provider/model", value="retry"),
543
+ questionary.Choice(
544
+ title="Keep these settings and finish — I'll edit the file myself",
545
+ value="keep",
546
+ ),
547
+ ],
548
+ ).ask()
549
+ if choice == "retry":
550
+ continue
551
+ # "keep" or None (cancelled) falls through to the next-steps block.
552
+
553
+ _print_init_next_steps(path)
554
+ return
478
555
 
479
556
 
480
557
  @app.command()
@@ -692,7 +769,7 @@ def _print_error(msg: str) -> None:
692
769
  _typer.echo(_click.style(f" ERROR: {msg}", fg="red"), err=True)
693
770
 
694
771
 
695
- def _print_doctor_result(result: DoctorCheckResult) -> None:
772
+ def _print_doctor_result(result: DoctorCheckResult, *, verbose: bool = False) -> None:
696
773
  import click as _click
697
774
 
698
775
  if result.name == "done":
@@ -703,11 +780,27 @@ def _print_doctor_result(result: DoctorCheckResult) -> None:
703
780
  _typer.echo(f" {line}")
704
781
  for err in result.errors:
705
782
  _typer.echo(_click.style(f" ERROR: {err}", fg="red"), err=True)
783
+ if result.traceback:
784
+ if verbose:
785
+ for line in result.traceback.splitlines():
786
+ _typer.echo(_click.style(f" {line}", fg="bright_black"), err=True)
787
+ else:
788
+ _typer.echo(
789
+ _click.style(" Run `ccc doctor -v` for the full traceback.", fg="bright_black"),
790
+ err=True,
791
+ )
706
792
 
707
793
 
708
794
  @app.command()
709
795
  @_catch_daemon_start_error
710
- def doctor() -> None:
796
+ def doctor(
797
+ verbose: bool = _typer.Option(
798
+ False,
799
+ "-v",
800
+ "--verbose",
801
+ help="Show full exception tracebacks for failed checks.",
802
+ ),
803
+ ) -> None:
711
804
  """Check system health and report issues."""
712
805
  from . import client as _client
713
806
  from .settings import (
@@ -717,6 +810,9 @@ def doctor() -> None:
717
810
  load_user_settings as _load_user_settings,
718
811
  )
719
812
 
813
+ def _on_result(result: DoctorCheckResult) -> None:
814
+ _print_doctor_result(result, verbose=verbose)
815
+
720
816
  # --- 1. Global settings (local, no daemon needed) ---
721
817
  _print_section("Global Settings")
722
818
  settings_path = user_settings_path()
@@ -770,7 +866,7 @@ def doctor() -> None:
770
866
  try:
771
867
  _client.doctor(
772
868
  project_root=None,
773
- on_result=_print_doctor_result,
869
+ on_result=_on_result,
774
870
  )
775
871
  except Exception as e:
776
872
  _print_error(f"Model check failed: {e}")
@@ -801,7 +897,7 @@ def doctor() -> None:
801
897
  try:
802
898
  _client.doctor(
803
899
  project_root=str(project_root),
804
- on_result=_print_doctor_result,
900
+ on_result=_on_result,
805
901
  )
806
902
  except Exception as e:
807
903
  _print_error(f"Project checks failed: {e}")
@@ -111,25 +111,33 @@ def _connect_and_handshake() -> Connection:
111
111
 
112
112
  Returns the open connection for the caller to send exactly one request.
113
113
 
114
- On the first call, automatically starts or
115
- restarts the daemon if needed. Subsequent calls fail fast with
116
- ``DaemonVersionError`` on mismatch (indicating the daemon was replaced
117
- mid-session, e.g. after a tool upgrade).
114
+ Automatically starts or restarts the daemon when it is absent or running
115
+ with stale global settings (e.g. a ``ccc init`` retry rewrote
116
+ ``global_settings.yml`` after the daemon loaded it). A genuine *version*
117
+ mismatch after we have already reached a matching daemon means the binary
118
+ was replaced under us mid-session — that fails fast instead of looping on
119
+ restarts.
118
120
  """
119
121
  global _daemon_ensured # noqa: PLW0603
120
122
 
121
- if _daemon_ensured:
122
- return _raw_connect_and_handshake()
123
-
124
- # First connection — auto-start/restart as needed.
125
123
  try:
126
124
  conn = _raw_connect_and_handshake()
127
125
  _daemon_ensured = True
128
126
  return conn
129
- except DaemonVersionError:
127
+ except DaemonVersionError as e:
128
+ # `resp.ok` is False only for a real version mismatch. Once we have
129
+ # ensured a matching daemon, a fresh version mismatch means the binary
130
+ # was swapped under us — fail fast. A settings-only restart request
131
+ # (resp.ok True, but the loaded settings mtime moved) is expected;
132
+ # restart the daemon below so it reloads them.
133
+ if _daemon_ensured and not e.resp.ok:
134
+ raise
130
135
  stop_daemon()
131
136
  except (ConnectionRefusedError, OSError):
132
- pass
137
+ # No daemon answered. Normal on the first call (start one below); if we
138
+ # had already ensured one it vanished mid-session — surface that.
139
+ if _daemon_ensured:
140
+ raise
133
141
 
134
142
  if _is_daemon_supervised():
135
143
  # Supervisor is responsible for (re)starting the daemon — just wait
@@ -192,10 +200,16 @@ class DaemonVersionError(RuntimeError):
192
200
 
193
201
  def __init__(self, resp: HandshakeResponse) -> None:
194
202
  self.resp = resp
195
- super().__init__(
196
- f"Daemon version mismatch (daemon={resp.daemon_version}, "
197
- f"client={__version__}). Please retry — the daemon may need a restart."
198
- )
203
+ if not resp.ok:
204
+ message = (
205
+ f"Daemon version mismatch (daemon={resp.daemon_version}, "
206
+ f"client={__version__}). Please retry — the daemon may need a restart."
207
+ )
208
+ else:
209
+ message = (
210
+ "Daemon is running with stale global settings and needs a restart. Please retry."
211
+ )
212
+ super().__init__(message)
199
213
 
200
214
 
201
215
  class DaemonStartError(RuntimeError):
@@ -363,7 +377,10 @@ def doctor(
363
377
  raise RuntimeError("Connection to daemon lost during doctor checks")
364
378
  resp = decode_response(data)
365
379
  if isinstance(resp, ErrorResponse):
366
- raise RuntimeError(f"Daemon error: {resp.message}")
380
+ detail = f"Daemon error: {resp.message}"
381
+ if resp.traceback:
382
+ detail += f"\n{resp.traceback}"
383
+ raise RuntimeError(detail)
367
384
  if isinstance(resp, DoctorResponse):
368
385
  results.append(resp.result)
369
386
  if on_result is not None:
@@ -10,6 +10,7 @@ import signal
10
10
  import sys
11
11
  import threading
12
12
  import time
13
+ import traceback
13
14
  from collections.abc import AsyncIterator, Callable
14
15
  from multiprocessing.connection import Connection, Listener
15
16
  from pathlib import Path
@@ -250,7 +251,11 @@ async def handle_connection(
250
251
  conn.send_bytes(encode_response(resp))
251
252
  except Exception as exc:
252
253
  logger.exception("Error during streaming response")
253
- conn.send_bytes(encode_response(ErrorResponse(message=str(exc))))
254
+ conn.send_bytes(
255
+ encode_response(
256
+ ErrorResponse(message=str(exc), traceback=traceback.format_exc())
257
+ )
258
+ )
254
259
  else:
255
260
  conn.send_bytes(encode_response(result))
256
261
  except (EOFError, OSError, asyncio.CancelledError):
@@ -359,6 +364,7 @@ async def _check_model(
359
364
  ok=False,
360
365
  details=[params_detail],
361
366
  errors=[result.error],
367
+ traceback=result.traceback,
362
368
  )
363
369
 
364
370
 
@@ -154,6 +154,9 @@ class DoctorCheckResult(_msgspec.Struct):
154
154
  ok: bool
155
155
  details: list[str]
156
156
  errors: list[str]
157
+ # Full formatted traceback for a failed check, shown by `ccc doctor` to aid
158
+ # debugging of daemon-side exceptions (e.g. a failing model check).
159
+ traceback: str | None = None
157
160
 
158
161
 
159
162
  class DoctorResponse(_msgspec.Struct, tag="doctor"):
@@ -175,6 +178,9 @@ class DaemonEnvResponse(_msgspec.Struct, tag="daemon_env"):
175
178
 
176
179
  class ErrorResponse(_msgspec.Struct, tag="error"):
177
180
  message: str
181
+ # Full formatted traceback from the daemon, when the error originates from an
182
+ # unhandled exception. Surfaced by the CLI so daemon-side failures are debuggable.
183
+ traceback: str | None = None
178
184
 
179
185
 
180
186
  Response = (
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import importlib.util
6
6
  import logging
7
7
  import pathlib
8
+ import traceback as _tb
8
9
  from dataclasses import dataclass
9
10
  from typing import TYPE_CHECKING, Annotated, Any, NamedTuple, Union
10
11
 
@@ -47,11 +48,14 @@ def is_sentence_transformers_installed() -> bool:
47
48
  class EmbeddingCheckResult(NamedTuple):
48
49
  """Outcome of a single embed-test call. See `check_embedding`.
49
50
 
50
- Exactly one of ``dim`` / ``error`` is set: ``error is None`` means success.
51
+ On success ``error is None`` and ``dim`` holds the embedding dimension. On
52
+ failure ``error`` holds a one-line summary and ``traceback`` the full
53
+ formatted traceback (for surfacing daemon-side stack traces in `ccc doctor`).
51
54
  """
52
55
 
53
56
  dim: int | None
54
57
  error: str | None
58
+ traceback: str | None = None
55
59
 
56
60
 
57
61
  async def check_embedding(
@@ -73,7 +77,7 @@ async def check_embedding(
73
77
  msg = " ".join(f"{type(e).__name__}: {e}".splitlines())
74
78
  if len(msg) > 500:
75
79
  msg = msg[:500] + "…"
76
- return EmbeddingCheckResult(dim=None, error=msg)
80
+ return EmbeddingCheckResult(dim=None, error=msg, traceback=_tb.format_exc())
77
81
 
78
82
 
79
83
  def create_embedder(
File without changes