cocoindex-code 0.2.42__tar.gz → 0.2.43__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 (27) hide show
  1. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/PKG-INFO +33 -25
  2. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/README.md +32 -24
  3. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/_daemon_paths.py +40 -0
  4. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/_version.py +2 -2
  5. cocoindex_code-0.2.43/src/cocoindex_code/chunking.py +54 -0
  6. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/cli.py +28 -8
  7. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/client.py +15 -9
  8. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/daemon.py +123 -30
  9. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/indexer.py +15 -7
  10. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/project.py +42 -5
  11. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/server.py +26 -5
  12. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/settings.py +48 -0
  13. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/shared.py +2 -1
  14. cocoindex_code-0.2.42/src/cocoindex_code/chunking.py +0 -29
  15. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/.gitignore +0 -0
  16. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/LICENSE +0 -0
  17. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/pyproject.toml +0 -0
  18. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/__init__.py +0 -0
  19. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/__main__.py +0 -0
  20. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/embedder_defaults.py +0 -0
  21. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/embedder_params.py +0 -0
  22. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/file_walk.py +0 -0
  23. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/grep.py +0 -0
  24. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/litellm_embedder.py +0 -0
  25. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/protocol.py +0 -0
  26. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/query.py +0 -0
  27. {cocoindex_code-0.2.42 → cocoindex_code-0.2.43}/src/cocoindex_code/schema.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cocoindex-code
3
- Version: 0.2.42
3
+ Version: 0.2.43
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
@@ -47,15 +47,15 @@ Description-Content-Type: text/markdown
47
47
  </p>
48
48
 
49
49
 
50
- <h1 align="center">AST-based semantic code search that just works</h1>
50
+ <h1 align="center">AST-based semantic code search</h1>
51
51
 
52
52
  ![effect](https://github.com/user-attachments/assets/cb3a4cae-0e1f-49c4-890b-7bb93317ab60)
53
53
 
54
54
 
55
- A lightweight, effective **(AST-based)** semantic code search tool for your codebase. Built on [CocoIndex](https://github.com/cocoindex-io/cocoindex) — a Rust-based ultra performant data transformation engine. Use it from the CLI, or integrate with Claude, Codex, Cursor — any coding agent — via [Skill](#skill-recommended) or [MCP](#mcp-server).
55
+ CocoIndex Code provides AST-based semantic search for your codebase, built on the [CocoIndex](https://github.com/cocoindex-io/cocoindex) Rust data transformation engine. Use it from the CLI or connect Claude, Codex, Cursor, and other coding agents through a [Skill](#skill-recommended) or [MCP](#mcp-server).
56
56
 
57
- - Instant token saving by 70%.
58
- - **1 min setup** — install and go, zero config needed!
57
+ - 70% token savings.
58
+ - 1 min setup with default settings.
59
59
 
60
60
  <div align="center">
61
61
 
@@ -69,7 +69,7 @@ A lightweight, effective **(AST-based)** semantic code search tool for your code
69
69
  [![release](https://github.com/cocoindex-io/cocoindex/actions/workflows/release.yml/badge.svg?event=push&color=5B5BD6)](https://github.com/cocoindex-io/cocoindex/actions/workflows/release.yml)
70
70
 
71
71
 
72
- 🌟 Please help star [CocoIndex](https://github.com/cocoindex-io/cocoindex) if you like this project!
72
+ Star [CocoIndex](https://github.com/cocoindex-io/cocoindex) if you find it useful.
73
73
 
74
74
  [Deutsch](https://readme-i18n.com/cocoindex-io/cocoindex-code?lang=de) |
75
75
  [English](https://readme-i18n.com/cocoindex-io/cocoindex-code?lang=en) |
@@ -84,7 +84,9 @@ A lightweight, effective **(AST-based)** semantic code search tool for your code
84
84
  </div>
85
85
 
86
86
 
87
- ## Get Started — zero config, let's go!
87
+ <a id="get-started--zero-config-lets-go"></a>
88
+
89
+ ## Get Started
88
90
 
89
91
  ### Install
90
92
 
@@ -117,7 +119,7 @@ Install the `ccc` skill so your coding agent automatically uses semantic search
117
119
  npx skills add cocoindex-io/cocoindex-code
118
120
  ```
119
121
 
120
- That's it — no `ccc init` or `ccc index` needed. The skill teaches the agent to handle initialization, indexing, and searching on its own. It will automatically keep the index up to date as you work.
122
+ The skill teaches the agent to handle initialization, indexing, and searching, so you don't need to run `ccc init` or `ccc index` yourself. The agent keeps the index up to date as you work.
121
123
 
122
124
  The agent uses semantic search automatically when it would be helpful. You can also nudge it explicitly — just ask it to search the codebase, e.g. *"find how user sessions are managed"*, or type `/ccc` to invoke the skill directly.
123
125
 
@@ -141,7 +143,7 @@ For [Grok](https://github.com/xai-org/grok) users, install via Grok's plugin sys
141
143
  | Component | Purpose |
142
144
  |-----------|---------|
143
145
  | **Skill** (`skills/ccc/`) | Agent runs `ccc search` / `ccc index` via the CLI (same as Claude Code above) |
144
- | **Hook** (`hooks/hooks.json`) | `SessionStart` + `PostToolUse` (Edit/Write/…) → incremental `ccc index` when `.cocoindex_code/` exists |
146
+ | **Hook** (`hooks/hooks.json`) | `SessionStart` + `PostToolUse` (Edit/Write/…) → incremental `ccc index` when `.cocoindex_code/settings.yml` exists (never in the directory holding ccc's user settings, `~` by default) |
145
147
  | **MCP** (`.mcp.json`) | `ccc mcp` stdio server — `search` tool with `refresh_index=true` by default |
146
148
 
147
149
  Grok does **not** import Claude's `enabledPlugins` or plugin cache; install separately even if you already use cocoindex in Claude Code.
@@ -196,7 +198,7 @@ Then restart the session (`/reload-plugins` does not reload extensions). Require
196
198
  | Component | Purpose |
197
199
  |-----------|---------|
198
200
  | **Skill** (`skills/ccc/`) | Agent runs `ccc search` / `ccc index` via the CLI |
199
- | **Hook** (`extensions/ccc-index.ts` via `package.json#omp.extensions`) | `session_start` + post-edit `tool_result` → incremental `ccc index` when `.cocoindex_code/` exists |
201
+ | **Hook** (`extensions/ccc-index.ts` via `package.json#omp.extensions`) | `session_start` + post-edit `tool_result` → incremental `ccc index` when `.cocoindex_code/settings.yml` exists (never in the directory holding ccc's user settings, `~` by default) |
200
202
  | **MCP** (`.mcp.json`) | `ccc mcp` stdio server |
201
203
 
202
204
  OMP does **not** execute Claude `hooks/hooks.json` command hooks. The TypeScript extension is the OMP equivalent of the Claude/Grok SessionStart + PostToolUse pair.
@@ -205,6 +207,9 @@ Skill-only (no MCP, no auto-index): install the skill via `npx skills add cocoin
205
207
 
206
208
  ### MCP Server
207
209
 
210
+ <!-- Ownership marker for the official MCP registry. It must match "name" in server.json. Keep the next line. -->
211
+ <!-- mcp-name: io.github.cocoindex-io/cocoindex-code -->
212
+
208
213
  Alternatively, use `ccc mcp` to run as an MCP server:
209
214
 
210
215
  <details>
@@ -284,7 +289,7 @@ Prefer the marketplace install above. Manual project MCP (`.omp/mcp.json`):
284
289
  ```
285
290
  </details>
286
291
 
287
- Once configured, the agent automatically decides when semantic code search is helpful — finding code by description, exploring unfamiliar codebases, fuzzy/conceptual matches, or locating implementations without knowing exact names.
292
+ Once configured, the agent can use semantic search to find code by description, explore unfamiliar codebases, and locate implementations without exact names.
288
293
 
289
294
  > **Note:** The `cocoindex-code` command (without subcommand) still works as an MCP server for backward compatibility. It auto-creates settings from environment variables on first run.
290
295
 
@@ -323,6 +328,8 @@ The background daemon starts automatically on first use.
323
328
 
324
329
  > **Tip:** You can skip `ccc init` and go straight to `ccc index` — it auto-initializes new projects with default settings. If global settings are missing too (first use on the machine), it walks you through the same model setup as `ccc init` when run interactively; non-interactive runs (scripts, hooks) still require a one-time `ccc init` first.
325
330
 
331
+ > **Note:** `ccc index` never auto-initializes the directory that holds ccc's user settings directory: your home directory by default (`~/.cocoindex_code` holds the global settings and daemon files), or the parent of `$COCOINDEX_CODE_DIR` when that directory is named `.cocoindex_code`. A project there would cover every repo below it. The editor hooks skip that directory even if it is already a project. If an older ccc made your home directory a project (`~/.cocoindex_code/settings.yml` exists), run `ccc reset --all` in your home directory: it removes that project's settings and indexes and keeps your global settings. To index your whole home directory on purpose, run `ccc init` there.
332
+
326
333
  ### CLI Reference
327
334
 
328
335
  | Command | Description |
@@ -475,8 +482,8 @@ ccc() {
475
482
  }
476
483
  ```
477
484
 
478
- Now `cd` into any project under your workspace and run `ccc init`, `ccc index`,
479
- `ccc search ...`, `ccc status`, etc. — it just works.
485
+ Now `cd` into a project under your workspace and run `ccc init`, `ccc index`,
486
+ `ccc search ...`, or `ccc status`.
480
487
 
481
488
  ### Connect your coding agent
482
489
 
@@ -565,11 +572,11 @@ docker build -t cocoindex-code:local -f docker/Dockerfile .
565
572
  ```
566
573
 
567
574
  ## Features
568
- - **Semantic Code Search**: Find relevant code using natural language queries when grep doesn't work well, and save tokens immediately.
569
- - **Ultra Performant**: ⚡ Built on top of ultra performant [Rust indexing engine](https://github.com/cocoindex-io/cocoindex). Only re-indexes changed files for fast updates.
575
+ - **Semantic Code Search**: Find relevant code using natural language queries when grep doesn't work well.
576
+ - **Incremental indexing**: The [Rust indexing engine](https://github.com/cocoindex-io/cocoindex) re-indexes only changed files.
570
577
  - **Multi-Language Support**: Python, JavaScript/TypeScript, Rust, Go, Java, C/C++, C#, SQL, Shell, and more.
571
- - **Embedded**: Portable and just works, no database setup required!
572
- - **Flexible Embeddings**: Local SentenceTransformers via the `[full]` extra (free, no API key!) or 100+ cloud providers via LiteLLM.
578
+ - **Embedded storage**: No database setup required.
579
+ - **Embedding providers**: Local SentenceTransformers via the `[full]` extra, with no API key, or 100+ cloud providers via LiteLLM.
573
580
 
574
581
  ## Configuration
575
582
 
@@ -685,8 +692,9 @@ After editing `include_patterns`, `exclude_patterns`, `max_file_size`, or `langu
685
692
  - Run `ccc doctor` to preview which files match.
686
693
  - Run `ccc index` or `ccc search --refresh ...` to update the existing index.
687
694
  - You do not need to delete the index or restart the daemon for these file-matching changes.
695
+ - After any `language_overrides` change, the next run re-chunks all files, including files whose content did not change; the run reports them as reprocessed. Chunks whose text did not change keep their cached embeddings.
688
696
 
689
- If you add or change custom `chunkers`, restart the daemon first so the chunker registry is reloaded, then run `ccc index`.
697
+ If you add or change custom `chunkers`, or edit a chunker module, restart the daemon (`ccc daemon restart`), then run `ccc index`. The daemon imports chunkers once at startup, so until it restarts it keeps running the old chunker code. The first run after the restart re-chunks all files, not only the ones the changed entry applies to; chunks whose text did not change keep their cached embeddings. A chunker counts as changed when its `module:` value changes or when the file of the module it names changes. Edits to other modules that file imports are not detected: after such an edit, also change the chunker's own module file (for example, bump a version comment in it).
690
698
 
691
699
  Use `chunkers` when you want to control how a file type is split into chunks before indexing.
692
700
 
@@ -938,7 +946,7 @@ Both source and target must be absolute paths. If no mapping matches, the defaul
938
946
 
939
947
  ## Troubleshooting
940
948
 
941
- Run `ccc doctor` to diagnose common issues. It checks your settings, daemon health, embedding model, file matching, and index status — all in one command.
949
+ Run `ccc doctor` to check your settings, daemon health, embedding model, file matching, and index status.
942
950
 
943
951
  ### `sqlite3.Connection object has no attribute enable_load_extension`
944
952
 
@@ -950,7 +958,7 @@ Some Python installations (e.g. the one pre-installed on macOS) ship with a SQLi
950
958
  brew install python3
951
959
  ```
952
960
 
953
- Then re-install cocoindex-code (see [Get Started](#get-started--zero-config-lets-go) for install options):
961
+ Then re-install cocoindex-code (see [Get Started](#get-started) for install options):
954
962
 
955
963
  Using pipx:
956
964
  ```bash
@@ -965,7 +973,7 @@ uv tool install --upgrade cocoindex-code
965
973
 
966
974
  ### `MDB_MAP_FULL: Environment mapsize limit reached`
967
975
 
968
- The index is stored in an LMDB database whose maximum size is fixed when the daemon starts. The default ceiling is **4 GiB**, which is plenty for most projects but can be exhausted by very large codebases (tens of thousands of files), especially with high-dimensional embedding models like `nomic-ai/CodeRankEmbed`.
976
+ The index is stored in an LMDB database whose maximum size is fixed when the daemon starts. The default ceiling is **4 GiB**, which codebases with tens of thousands of files can exhaust, especially with high-dimensional embedding models like `nomic-ai/CodeRankEmbed`.
969
977
 
970
978
  Raise the ceiling with the `COCOINDEX_LMDB_MAP_SIZE` environment variable (value in **bytes**). LMDB only grows the file as data is written, so a high limit doesn't pre-allocate disk — it's safe to set it generously:
971
979
 
@@ -1015,15 +1023,15 @@ export COCOINDEX_DISABLE_USAGE_TRACKING=1
1015
1023
  ```
1016
1024
 
1017
1025
  ## Large codebase / Enterprise
1018
- [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.
1026
+ Teams indexing large or multiple repositories can share [CocoIndex](https://github.com/cocoindex-io/cocoindex) indexes across developers. Enterprise features include deduplication across branches.
1019
1027
 
1020
- > Indexing a very large codebase and hitting `MDB_MAP_FULL`? Raise the LMDB map size — see [`MDB_MAP_FULL: Environment mapsize limit reached`](#mdb_map_full-environment-mapsize-limit-reached) under Troubleshooting.
1028
+ > If indexing fails with `MDB_MAP_FULL`, raise the LMDB map size. See [`MDB_MAP_FULL: Environment mapsize limit reached`](#mdb_map_full-environment-mapsize-limit-reached) under Troubleshooting.
1021
1029
 
1022
- If you need help with remote setup, please email our maintainer linghua@cocoindex.io, happy to help!
1030
+ For help with remote setup, email linghua@cocoindex.io.
1023
1031
 
1024
1032
  ## Contributing
1025
1033
 
1026
- We welcome contributions! This project uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for development, and every PR is gated on the same lint, format, type-check, and test suite in CI. **Please run these checks locally before opening a PR** — failing pre-commit checks are the most common cause of red CI on incoming PRs.
1034
+ We welcome contributions. This project uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for development. CI checks lint, formatting, types, and tests for every PR. Run the same checks locally before opening a PR; failed pre-commit checks are the most common cause of red CI on incoming PRs.
1027
1035
 
1028
1036
  ### 1. Install the dev dependencies
1029
1037
 
@@ -3,15 +3,15 @@
3
3
  </p>
4
4
 
5
5
 
6
- <h1 align="center">AST-based semantic code search that just works</h1>
6
+ <h1 align="center">AST-based semantic code search</h1>
7
7
 
8
8
  ![effect](https://github.com/user-attachments/assets/cb3a4cae-0e1f-49c4-890b-7bb93317ab60)
9
9
 
10
10
 
11
- A lightweight, effective **(AST-based)** semantic code search tool for your codebase. Built on [CocoIndex](https://github.com/cocoindex-io/cocoindex) — a Rust-based ultra performant data transformation engine. Use it from the CLI, or integrate with Claude, Codex, Cursor — any coding agent — via [Skill](#skill-recommended) or [MCP](#mcp-server).
11
+ CocoIndex Code provides AST-based semantic search for your codebase, built on the [CocoIndex](https://github.com/cocoindex-io/cocoindex) Rust data transformation engine. Use it from the CLI or connect Claude, Codex, Cursor, and other coding agents through a [Skill](#skill-recommended) or [MCP](#mcp-server).
12
12
 
13
- - Instant token saving by 70%.
14
- - **1 min setup** — install and go, zero config needed!
13
+ - 70% token savings.
14
+ - 1 min setup with default settings.
15
15
 
16
16
  <div align="center">
17
17
 
@@ -25,7 +25,7 @@ A lightweight, effective **(AST-based)** semantic code search tool for your code
25
25
  [![release](https://github.com/cocoindex-io/cocoindex/actions/workflows/release.yml/badge.svg?event=push&color=5B5BD6)](https://github.com/cocoindex-io/cocoindex/actions/workflows/release.yml)
26
26
 
27
27
 
28
- 🌟 Please help star [CocoIndex](https://github.com/cocoindex-io/cocoindex) if you like this project!
28
+ Star [CocoIndex](https://github.com/cocoindex-io/cocoindex) if you find it useful.
29
29
 
30
30
  [Deutsch](https://readme-i18n.com/cocoindex-io/cocoindex-code?lang=de) |
31
31
  [English](https://readme-i18n.com/cocoindex-io/cocoindex-code?lang=en) |
@@ -40,7 +40,9 @@ A lightweight, effective **(AST-based)** semantic code search tool for your code
40
40
  </div>
41
41
 
42
42
 
43
- ## Get Started — zero config, let's go!
43
+ <a id="get-started--zero-config-lets-go"></a>
44
+
45
+ ## Get Started
44
46
 
45
47
  ### Install
46
48
 
@@ -73,7 +75,7 @@ Install the `ccc` skill so your coding agent automatically uses semantic search
73
75
  npx skills add cocoindex-io/cocoindex-code
74
76
  ```
75
77
 
76
- That's it — no `ccc init` or `ccc index` needed. The skill teaches the agent to handle initialization, indexing, and searching on its own. It will automatically keep the index up to date as you work.
78
+ The skill teaches the agent to handle initialization, indexing, and searching, so you don't need to run `ccc init` or `ccc index` yourself. The agent keeps the index up to date as you work.
77
79
 
78
80
  The agent uses semantic search automatically when it would be helpful. You can also nudge it explicitly — just ask it to search the codebase, e.g. *"find how user sessions are managed"*, or type `/ccc` to invoke the skill directly.
79
81
 
@@ -97,7 +99,7 @@ For [Grok](https://github.com/xai-org/grok) users, install via Grok's plugin sys
97
99
  | Component | Purpose |
98
100
  |-----------|---------|
99
101
  | **Skill** (`skills/ccc/`) | Agent runs `ccc search` / `ccc index` via the CLI (same as Claude Code above) |
100
- | **Hook** (`hooks/hooks.json`) | `SessionStart` + `PostToolUse` (Edit/Write/…) → incremental `ccc index` when `.cocoindex_code/` exists |
102
+ | **Hook** (`hooks/hooks.json`) | `SessionStart` + `PostToolUse` (Edit/Write/…) → incremental `ccc index` when `.cocoindex_code/settings.yml` exists (never in the directory holding ccc's user settings, `~` by default) |
101
103
  | **MCP** (`.mcp.json`) | `ccc mcp` stdio server — `search` tool with `refresh_index=true` by default |
102
104
 
103
105
  Grok does **not** import Claude's `enabledPlugins` or plugin cache; install separately even if you already use cocoindex in Claude Code.
@@ -152,7 +154,7 @@ Then restart the session (`/reload-plugins` does not reload extensions). Require
152
154
  | Component | Purpose |
153
155
  |-----------|---------|
154
156
  | **Skill** (`skills/ccc/`) | Agent runs `ccc search` / `ccc index` via the CLI |
155
- | **Hook** (`extensions/ccc-index.ts` via `package.json#omp.extensions`) | `session_start` + post-edit `tool_result` → incremental `ccc index` when `.cocoindex_code/` exists |
157
+ | **Hook** (`extensions/ccc-index.ts` via `package.json#omp.extensions`) | `session_start` + post-edit `tool_result` → incremental `ccc index` when `.cocoindex_code/settings.yml` exists (never in the directory holding ccc's user settings, `~` by default) |
156
158
  | **MCP** (`.mcp.json`) | `ccc mcp` stdio server |
157
159
 
158
160
  OMP does **not** execute Claude `hooks/hooks.json` command hooks. The TypeScript extension is the OMP equivalent of the Claude/Grok SessionStart + PostToolUse pair.
@@ -161,6 +163,9 @@ Skill-only (no MCP, no auto-index): install the skill via `npx skills add cocoin
161
163
 
162
164
  ### MCP Server
163
165
 
166
+ <!-- Ownership marker for the official MCP registry. It must match "name" in server.json. Keep the next line. -->
167
+ <!-- mcp-name: io.github.cocoindex-io/cocoindex-code -->
168
+
164
169
  Alternatively, use `ccc mcp` to run as an MCP server:
165
170
 
166
171
  <details>
@@ -240,7 +245,7 @@ Prefer the marketplace install above. Manual project MCP (`.omp/mcp.json`):
240
245
  ```
241
246
  </details>
242
247
 
243
- Once configured, the agent automatically decides when semantic code search is helpful — finding code by description, exploring unfamiliar codebases, fuzzy/conceptual matches, or locating implementations without knowing exact names.
248
+ Once configured, the agent can use semantic search to find code by description, explore unfamiliar codebases, and locate implementations without exact names.
244
249
 
245
250
  > **Note:** The `cocoindex-code` command (without subcommand) still works as an MCP server for backward compatibility. It auto-creates settings from environment variables on first run.
246
251
 
@@ -279,6 +284,8 @@ The background daemon starts automatically on first use.
279
284
 
280
285
  > **Tip:** You can skip `ccc init` and go straight to `ccc index` — it auto-initializes new projects with default settings. If global settings are missing too (first use on the machine), it walks you through the same model setup as `ccc init` when run interactively; non-interactive runs (scripts, hooks) still require a one-time `ccc init` first.
281
286
 
287
+ > **Note:** `ccc index` never auto-initializes the directory that holds ccc's user settings directory: your home directory by default (`~/.cocoindex_code` holds the global settings and daemon files), or the parent of `$COCOINDEX_CODE_DIR` when that directory is named `.cocoindex_code`. A project there would cover every repo below it. The editor hooks skip that directory even if it is already a project. If an older ccc made your home directory a project (`~/.cocoindex_code/settings.yml` exists), run `ccc reset --all` in your home directory: it removes that project's settings and indexes and keeps your global settings. To index your whole home directory on purpose, run `ccc init` there.
288
+
282
289
  ### CLI Reference
283
290
 
284
291
  | Command | Description |
@@ -431,8 +438,8 @@ ccc() {
431
438
  }
432
439
  ```
433
440
 
434
- Now `cd` into any project under your workspace and run `ccc init`, `ccc index`,
435
- `ccc search ...`, `ccc status`, etc. — it just works.
441
+ Now `cd` into a project under your workspace and run `ccc init`, `ccc index`,
442
+ `ccc search ...`, or `ccc status`.
436
443
 
437
444
  ### Connect your coding agent
438
445
 
@@ -521,11 +528,11 @@ docker build -t cocoindex-code:local -f docker/Dockerfile .
521
528
  ```
522
529
 
523
530
  ## Features
524
- - **Semantic Code Search**: Find relevant code using natural language queries when grep doesn't work well, and save tokens immediately.
525
- - **Ultra Performant**: ⚡ Built on top of ultra performant [Rust indexing engine](https://github.com/cocoindex-io/cocoindex). Only re-indexes changed files for fast updates.
531
+ - **Semantic Code Search**: Find relevant code using natural language queries when grep doesn't work well.
532
+ - **Incremental indexing**: The [Rust indexing engine](https://github.com/cocoindex-io/cocoindex) re-indexes only changed files.
526
533
  - **Multi-Language Support**: Python, JavaScript/TypeScript, Rust, Go, Java, C/C++, C#, SQL, Shell, and more.
527
- - **Embedded**: Portable and just works, no database setup required!
528
- - **Flexible Embeddings**: Local SentenceTransformers via the `[full]` extra (free, no API key!) or 100+ cloud providers via LiteLLM.
534
+ - **Embedded storage**: No database setup required.
535
+ - **Embedding providers**: Local SentenceTransformers via the `[full]` extra, with no API key, or 100+ cloud providers via LiteLLM.
529
536
 
530
537
  ## Configuration
531
538
 
@@ -641,8 +648,9 @@ After editing `include_patterns`, `exclude_patterns`, `max_file_size`, or `langu
641
648
  - Run `ccc doctor` to preview which files match.
642
649
  - Run `ccc index` or `ccc search --refresh ...` to update the existing index.
643
650
  - You do not need to delete the index or restart the daemon for these file-matching changes.
651
+ - After any `language_overrides` change, the next run re-chunks all files, including files whose content did not change; the run reports them as reprocessed. Chunks whose text did not change keep their cached embeddings.
644
652
 
645
- If you add or change custom `chunkers`, restart the daemon first so the chunker registry is reloaded, then run `ccc index`.
653
+ If you add or change custom `chunkers`, or edit a chunker module, restart the daemon (`ccc daemon restart`), then run `ccc index`. The daemon imports chunkers once at startup, so until it restarts it keeps running the old chunker code. The first run after the restart re-chunks all files, not only the ones the changed entry applies to; chunks whose text did not change keep their cached embeddings. A chunker counts as changed when its `module:` value changes or when the file of the module it names changes. Edits to other modules that file imports are not detected: after such an edit, also change the chunker's own module file (for example, bump a version comment in it).
646
654
 
647
655
  Use `chunkers` when you want to control how a file type is split into chunks before indexing.
648
656
 
@@ -894,7 +902,7 @@ Both source and target must be absolute paths. If no mapping matches, the defaul
894
902
 
895
903
  ## Troubleshooting
896
904
 
897
- Run `ccc doctor` to diagnose common issues. It checks your settings, daemon health, embedding model, file matching, and index status — all in one command.
905
+ Run `ccc doctor` to check your settings, daemon health, embedding model, file matching, and index status.
898
906
 
899
907
  ### `sqlite3.Connection object has no attribute enable_load_extension`
900
908
 
@@ -906,7 +914,7 @@ Some Python installations (e.g. the one pre-installed on macOS) ship with a SQLi
906
914
  brew install python3
907
915
  ```
908
916
 
909
- Then re-install cocoindex-code (see [Get Started](#get-started--zero-config-lets-go) for install options):
917
+ Then re-install cocoindex-code (see [Get Started](#get-started) for install options):
910
918
 
911
919
  Using pipx:
912
920
  ```bash
@@ -921,7 +929,7 @@ uv tool install --upgrade cocoindex-code
921
929
 
922
930
  ### `MDB_MAP_FULL: Environment mapsize limit reached`
923
931
 
924
- The index is stored in an LMDB database whose maximum size is fixed when the daemon starts. The default ceiling is **4 GiB**, which is plenty for most projects but can be exhausted by very large codebases (tens of thousands of files), especially with high-dimensional embedding models like `nomic-ai/CodeRankEmbed`.
932
+ The index is stored in an LMDB database whose maximum size is fixed when the daemon starts. The default ceiling is **4 GiB**, which codebases with tens of thousands of files can exhaust, especially with high-dimensional embedding models like `nomic-ai/CodeRankEmbed`.
925
933
 
926
934
  Raise the ceiling with the `COCOINDEX_LMDB_MAP_SIZE` environment variable (value in **bytes**). LMDB only grows the file as data is written, so a high limit doesn't pre-allocate disk — it's safe to set it generously:
927
935
 
@@ -971,15 +979,15 @@ export COCOINDEX_DISABLE_USAGE_TRACKING=1
971
979
  ```
972
980
 
973
981
  ## Large codebase / Enterprise
974
- [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.
982
+ Teams indexing large or multiple repositories can share [CocoIndex](https://github.com/cocoindex-io/cocoindex) indexes across developers. Enterprise features include deduplication across branches.
975
983
 
976
- > Indexing a very large codebase and hitting `MDB_MAP_FULL`? Raise the LMDB map size — see [`MDB_MAP_FULL: Environment mapsize limit reached`](#mdb_map_full-environment-mapsize-limit-reached) under Troubleshooting.
984
+ > If indexing fails with `MDB_MAP_FULL`, raise the LMDB map size. See [`MDB_MAP_FULL: Environment mapsize limit reached`](#mdb_map_full-environment-mapsize-limit-reached) under Troubleshooting.
977
985
 
978
- If you need help with remote setup, please email our maintainer linghua@cocoindex.io, happy to help!
986
+ For help with remote setup, email linghua@cocoindex.io.
979
987
 
980
988
  ## Contributing
981
989
 
982
- We welcome contributions! This project uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for development, and every PR is gated on the same lint, format, type-check, and test suite in CI. **Please run these checks locally before opening a PR** — failing pre-commit checks are the most common cause of red CI on incoming PRs.
990
+ We welcome contributions. This project uses [uv](https://docs.astral.sh/uv/getting-started/installation/) for development. CI checks lint, formatting, types, and tests for every PR. Run the same checks locally before opening a PR; failed pre-commit checks are the most common cause of red CI on incoming PRs.
983
991
 
984
992
  ### 1. Install the dev dependencies
985
993
 
@@ -14,6 +14,7 @@ import tempfile
14
14
  import time
15
15
  from dataclasses import dataclass
16
16
  from pathlib import Path
17
+ from typing import NamedTuple
17
18
 
18
19
  from .settings import user_settings_dir
19
20
 
@@ -79,6 +80,45 @@ def daemon_socket_path() -> str:
79
80
  return str(short)
80
81
 
81
82
 
83
+ class SocketIdentity(NamedTuple):
84
+ """Identifies one socket file, telling a daemon's own apart from one a
85
+ replacement daemon has since bound at the same path (issue #288).
86
+
87
+ ``ctime_ns`` is part of it because ext4/overlayfs hand a freed inode
88
+ number straight to the next file: once the original socket is closed and
89
+ unlinked, a replacement's can have the same ``(dev, ino)``.
90
+ """
91
+
92
+ dev: int
93
+ ino: int
94
+ ctime_ns: int
95
+
96
+
97
+ def socket_identity(path: str) -> SocketIdentity | None:
98
+ """Return the identity of the socket file at *path*, or None if absent. POSIX only."""
99
+ try:
100
+ st = os.stat(path)
101
+ except OSError:
102
+ return None
103
+ return SocketIdentity(st.st_dev, st.st_ino, st.st_ctime_ns)
104
+
105
+
106
+ def unlink_socket_if_owned(path: str, identity: SocketIdentity | None) -> None:
107
+ """Remove the socket file at *path* only if it is still *identity*.
108
+
109
+ Leaves alone a socket a replacement daemon has bound there since. Not
110
+ atomic — POSIX has no unlink-if-inode — so a replacement binding between
111
+ the stat and the unlink is still lost, but that window is microseconds.
112
+ Best-effort — never raises.
113
+ """
114
+ if identity is None or socket_identity(path) != identity:
115
+ return
116
+ try:
117
+ os.unlink(path)
118
+ except OSError:
119
+ pass
120
+
121
+
82
122
  def daemon_pid_path() -> Path:
83
123
  """Return the path for the daemon's PID file."""
84
124
  return daemon_runtime_dir() / "daemon.pid"
@@ -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.42'
22
- __version_tuple__ = version_tuple = (0, 2, 42)
21
+ __version__ = version = '0.2.43'
22
+ __version_tuple__ = version_tuple = (0, 2, 43)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -0,0 +1,54 @@
1
+ """Public API for writing custom chunkers.
2
+
3
+ Example usage::
4
+
5
+ from pathlib import Path
6
+ from cocoindex_code.chunking import Chunk, ChunkerFn, TextPosition
7
+
8
+ def my_chunker(path: Path, content: str) -> tuple[str | None, list[Chunk]]:
9
+ pos = TextPosition(byte_offset=0, char_offset=0, line=1, column=0)
10
+ return "mylang", [Chunk(text=content, start=pos, end=pos)]
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses as _dataclasses
16
+ import pathlib as _pathlib
17
+ from collections.abc import Callable as _Callable
18
+
19
+ import cocoindex as _coco
20
+ from cocoindex.resources.chunk import Chunk, TextPosition
21
+
22
+ # Callable alias (not Protocol) — consistent with codebase style.
23
+ # language_override=None keeps the language detected by detect_code_language.
24
+ # path is not resolved (no syscall); call path.resolve() inside the chunker if needed.
25
+ ChunkerFn = _Callable[[_pathlib.Path, str], tuple[str | None, list[Chunk]]]
26
+
27
+
28
+ @_dataclasses.dataclass(frozen=True)
29
+ class LoadedChunker:
30
+ """A chunker the daemon imported from a ``settings.yml`` entry.
31
+
32
+ Its memo key is ``(spec, module_sha256)``, never ``fn`` itself: cocoindex
33
+ fingerprints a function by its module and name only, so an edit to its body
34
+ would go unnoticed, and it cannot fingerprint lambdas or closures at all.
35
+ """
36
+
37
+ fn: ChunkerFn
38
+ #: The ``"module.path:callable"`` string from ``settings.yml``.
39
+ spec: str
40
+ #: sha256 of the module file named in ``spec``, as it was when imported. Helper
41
+ #: modules that file imports are not covered.
42
+ module_sha256: str
43
+
44
+ def __coco_memo_key__(self) -> tuple[str, str]:
45
+ return (self.spec, self.module_sha256)
46
+
47
+
48
+ # Keyed by file suffix (e.g. ".toml"). Adding, removing or changing an entry
49
+ # re-processes all files.
50
+ CHUNKER_REGISTRY = _coco.ContextKey[dict[str, LoadedChunker]](
51
+ "chunker_registry", detect_change=True
52
+ )
53
+
54
+ __all__ = ["Chunk", "ChunkerFn", "CHUNKER_REGISTRY", "LoadedChunker", "TextPosition"]
@@ -26,6 +26,7 @@ from ._version import __version__
26
26
  from .settings import (
27
27
  DEFAULT_ST_MODEL,
28
28
  EmbeddingSettings,
29
+ auto_init_refusal,
29
30
  cocoindex_db_path,
30
31
  default_project_settings,
31
32
  find_parent_with_marker,
@@ -38,6 +39,7 @@ from .settings import (
38
39
  save_project_settings,
39
40
  target_sqlite_db_path,
40
41
  user_settings_path,
42
+ user_settings_root_note,
41
43
  )
42
44
 
43
45
  app = _typer.Typer(
@@ -81,13 +83,19 @@ def _apply_host_cwd() -> None:
81
83
  def require_project_root(*, auto_init: bool = False) -> Path:
82
84
  """Find the project root by walking up from CWD.
83
85
 
84
- Checks global settings first (more fundamental), then project settings.
86
+ Reports missing global settings before a missing project (more fundamental).
85
87
  With ``auto_init``, a missing project is initialized with default settings
86
- instead of failing. Missing global settings run the same interactive model
88
+ instead of failing, unless that would be the user settings root (refused
89
+ first). Missing global settings run the same interactive model
87
90
  setup as ``ccc init`` — but only on a TTY, since picking an embedding model
88
91
  is a consequential choice; non-interactive runs (scripts, hooks, agents)
89
92
  still exit with code 1 rather than silently committing to a default model.
90
93
  """
94
+ cwd = Path.cwd()
95
+ root = find_project_root(cwd)
96
+ # Decide where auto-init would write, and whether it may, before the interactive
97
+ # model setup below, so a refusal never comes after a wasted prompt.
98
+ new_root = _auto_init_root(cwd) if root is None and auto_init else None
91
99
  gs_path = user_settings_path()
92
100
  if not gs_path.is_file():
93
101
  if auto_init and sys.stdin.isatty():
@@ -99,27 +107,39 @@ def require_project_root(*, auto_init: bool = False) -> Path:
99
107
  err=True,
100
108
  )
101
109
  raise _typer.Exit(code=1)
102
- root = find_project_root(Path.cwd())
110
+ if new_root is not None:
111
+ _create_project_settings(new_root)
112
+ return new_root
103
113
  if root is None:
104
- if auto_init:
105
- return _auto_init_project(Path.cwd())
106
114
  _typer.echo(
107
115
  "Error: Not in an initialized project directory.\n"
108
116
  "Run `ccc init` in your project root to get started.",
109
117
  err=True,
110
118
  )
111
119
  raise _typer.Exit(code=1)
120
+ _note_if_user_settings_root(root)
112
121
  return root
113
122
 
114
123
 
115
- def _auto_init_project(cwd: Path) -> Path:
116
- """Create default project settings without an explicit ``ccc init``.
124
+ def _note_if_user_settings_root(root: Path) -> None:
125
+ """Tell the user when a repo below the user settings root resolves to it."""
126
+ note = user_settings_root_note(root, Path.cwd())
127
+ if note is not None:
128
+ _typer.echo(f"Note: {note}", err=True)
129
+
130
+
131
+ def _auto_init_root(cwd: Path) -> Path:
132
+ """Return where ``ccc index`` may create default project settings, or exit.
117
133
 
118
134
  Anchors at the nearest parent git root when there is one, so running from
119
135
  a repo subdirectory initializes the repo root rather than the subdirectory.
136
+ Never the user settings root (see ``auto_init_refusal``).
120
137
  """
121
138
  root = find_parent_with_marker(cwd) or cwd
122
- _create_project_settings(root)
139
+ refusal = auto_init_refusal(root)
140
+ if refusal is not None:
141
+ _typer.echo(f"Error: {refusal}", err=True)
142
+ raise _typer.Exit(code=1)
123
143
  return root
124
144
 
125
145
 
@@ -21,12 +21,15 @@ from typing import NamedTuple
21
21
  import msgspec
22
22
 
23
23
  from ._daemon_paths import (
24
+ SocketIdentity,
24
25
  connection_family,
25
26
  daemon_log_path,
26
27
  daemon_pid_path,
27
28
  daemon_runtime_dir,
28
29
  daemon_socket_path,
29
30
  read_last_exit_marker,
31
+ socket_identity,
32
+ unlink_socket_if_owned,
30
33
  )
31
34
  from ._version import __version__
32
35
  from .protocol import (
@@ -642,6 +645,9 @@ def stop_daemon() -> None:
642
645
  pid = None
643
646
  except (FileNotFoundError, ValueError):
644
647
  pass
648
+ # The socket file of the daemon being stopped. Cleanup removes only that
649
+ # one, never a replacement daemon's bound there meanwhile (issue #288).
650
+ sock_identity = socket_identity(daemon_socket_path()) if sys.platform != "win32" else None
645
651
 
646
652
  # 1) Graceful StopRequest via socket (bypass auto-start)
647
653
  try:
@@ -677,17 +683,17 @@ def stop_daemon() -> None:
677
683
  except (ProcessLookupError, PermissionError):
678
684
  pass
679
685
 
680
- _cleanup_stale_files(pid_path, pid)
686
+ _cleanup_stale_files(pid_path, pid, sock_identity)
681
687
 
682
688
 
683
- def _cleanup_stale_files(pid_path: Path, pid: int | None) -> None:
684
- """Remove socket and PID file after the daemon has exited."""
685
- if sys.platform != "win32":
686
- sock = daemon_socket_path()
687
- try:
688
- Path(sock).unlink(missing_ok=True)
689
- except Exception:
690
- pass
689
+ def _cleanup_stale_files(
690
+ pid_path: Path, pid: int | None, sock_identity: SocketIdentity | None
691
+ ) -> None:
692
+ """Remove socket and PID file after the daemon has exited.
693
+
694
+ The socket file is removed only while it is still *sock_identity*.
695
+ """
696
+ unlink_socket_if_owned(daemon_socket_path(), sock_identity)
691
697
  if pid is not None:
692
698
  try:
693
699
  stored = pid_path.read_text().strip()
@@ -3,19 +3,25 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextvars
7
+ import hashlib
6
8
  import importlib
7
9
  import logging
8
10
  import os
9
11
  import signal
12
+ import socket
10
13
  import sys
11
14
  import threading
12
15
  import time
13
16
  import traceback
17
+ import weakref
14
18
  from collections.abc import AsyncIterator, Callable
19
+ from concurrent.futures import Future, ThreadPoolExecutor
15
20
  from datetime import timedelta
16
21
  from multiprocessing.connection import Client, Connection, Listener
17
22
  from pathlib import Path
18
- from typing import Any
23
+ from types import ModuleType
24
+ from typing import Any, ParamSpec, TypeVar
19
25
 
20
26
  from ._daemon_paths import (
21
27
  clear_last_exit_marker,
@@ -24,10 +30,12 @@ from ._daemon_paths import (
24
30
  daemon_pid_path,
25
31
  daemon_runtime_dir,
26
32
  daemon_socket_path,
33
+ socket_identity,
34
+ unlink_socket_if_owned,
27
35
  write_last_exit_marker,
28
36
  )
29
37
  from ._version import __version__
30
- from .chunking import ChunkerFn as _ChunkerFn
38
+ from .chunking import LoadedChunker
31
39
  from .embedder_params import resolve_embedder_params
32
40
  from .project import Project
33
41
  from .protocol import (
@@ -99,22 +107,41 @@ def _build_backward_compat_warning(
99
107
  )
100
108
 
101
109
 
102
- def _resolve_chunker_registry(mappings: list[ChunkerMapping]) -> dict[str, _ChunkerFn]:
103
- """Resolve ``ChunkerMapping`` settings entries to a ``{suffix: fn}`` dict.
110
+ # sha256 of each chunker module's file, taken when the daemon first got the module.
111
+ # importlib returns the already loaded module on later imports (another project, or
112
+ # the same one after `ccc reset`), and that module still runs the code that was
113
+ # loaded, whatever the file holds now.
114
+ _chunker_module_sha256: weakref.WeakKeyDictionary[ModuleType, str] = weakref.WeakKeyDictionary()
115
+
116
+
117
+ def _module_sha256(mod: ModuleType) -> str:
118
+ digest = _chunker_module_sha256.get(mod)
119
+ if digest is None:
120
+ # Builtin modules have no file; their code only changes with Python itself.
121
+ file = getattr(mod, "__file__", None)
122
+ digest = hashlib.sha256(Path(file).read_bytes()).hexdigest() if file else ""
123
+ _chunker_module_sha256[mod] = digest
124
+ return digest
125
+
126
+
127
+ def _resolve_chunker_registry(mappings: list[ChunkerMapping]) -> dict[str, LoadedChunker]:
128
+ """Import the chunkers from ``ChunkerMapping`` settings entries, keyed by suffix.
104
129
 
105
130
  Each ``mapping.module`` must be a ``"module.path:callable"`` string importable
106
- from the current environment.
131
+ from the current environment. Each chunker records that string and a hash of
132
+ the module file it names, which is what makes a chunker edit re-process files.
107
133
  """
108
- registry: dict[str, _ChunkerFn] = {}
134
+ registry: dict[str, LoadedChunker] = {}
109
135
  for cm in mappings:
110
136
  module_path, _, attr = cm.module.partition(":")
111
137
  if not attr:
112
138
  raise ValueError(f"chunker module {cm.module!r} must use 'module.path:callable' format")
113
139
  mod = importlib.import_module(module_path)
140
+ module_sha256 = _module_sha256(mod)
114
141
  fn = getattr(mod, attr)
115
142
  if not callable(fn):
116
143
  raise ValueError(f"chunker {cm.module!r}: {attr!r} is not callable")
117
- registry[f".{cm.ext}"] = fn
144
+ registry[f".{cm.ext}"] = LoadedChunker(fn, spec=cm.module, module_sha256=module_sha256)
118
145
  return registry
119
146
 
120
147
 
@@ -161,13 +188,12 @@ class ProjectRegistry:
161
188
  if project_root not in self._projects:
162
189
  root = Path(project_root)
163
190
  project_settings = load_project_settings(root)
164
- chunker_registry = _resolve_chunker_registry(project_settings.chunkers)
165
191
  project = await Project.create(
166
192
  root,
167
193
  self._embedder,
168
194
  indexing_params=self.indexing_params,
169
195
  query_params=self.query_params,
170
- chunker_registry=chunker_registry,
196
+ chunker_registry=_resolve_chunker_registry(project_settings.chunkers),
171
197
  clear_mps_cache_after_index=self._clear_mps_cache_after_index,
172
198
  )
173
199
  self._projects[project_root] = project
@@ -613,6 +639,77 @@ async def _dispatch(
613
639
  # Daemon main
614
640
  # ---------------------------------------------------------------------------
615
641
 
642
+ _P = ParamSpec("_P")
643
+ _T = TypeVar("_T")
644
+
645
+
646
+ class EmptyContextThreadPoolExecutor(ThreadPoolExecutor):
647
+ """Thread pool whose worker threads start in an empty ``contextvars`` context.
648
+
649
+ ``submit()`` starts a worker thread when no idle one is free. On free-threaded
650
+ Python 3.14 (``sys.flags.thread_inherit_context``) that thread starts in a copy
651
+ of the submitter's context and keeps it for its whole life. A copy taken inside
652
+ a CocoIndex component references the project's CocoIndex environment, which is
653
+ only released once nothing references it, so it would outlive the project and
654
+ opening the project again in this process (as after ``ccc reset``) would fail.
655
+ Submitting from an empty context gives worker threads the empty context they
656
+ start with on GIL builds.
657
+ """
658
+
659
+ def submit(self, fn: Callable[_P, _T], /, *args: _P.args, **kwargs: _P.kwargs) -> Future[_T]:
660
+ return contextvars.Context().run(super().submit, fn, *args, **kwargs)
661
+
662
+
663
+ if sys.platform != "win32": # Windows uses named pipes via Listener
664
+
665
+ class _UnixListener:
666
+ """POSIX stand-in for ``multiprocessing.connection.Listener`` that removes
667
+ its socket file on ``close()`` only while the path still names it.
668
+
669
+ The stdlib listener unlinks its address on ``close()`` unconditionally, so
670
+ a displaced daemon shutting down would delete the socket a replacement
671
+ daemon had since bound at the same path, cutting clients off from it
672
+ (issue #288).
673
+ """
674
+
675
+ def __init__(self, path: str) -> None:
676
+ # Take over the path from a crashed or displaced daemon's socket file,
677
+ # which would otherwise make bind() fail.
678
+ try:
679
+ os.unlink(path)
680
+ except FileNotFoundError:
681
+ pass
682
+ self._path = path
683
+ self._sock = socket.socket(socket.AF_UNIX)
684
+ try:
685
+ self._sock.setblocking(True)
686
+ self._sock.bind(path)
687
+ self._sock.listen(1) # same backlog as the stdlib Listener
688
+ except OSError:
689
+ self._sock.close()
690
+ raise
691
+ # A replacement binding the path in the instant between bind() and
692
+ # this stat would be recorded as ours; bind() itself can't report the
693
+ # file's identity, and the window is microseconds.
694
+ self._identity = socket_identity(path)
695
+
696
+ def accept(self) -> Connection:
697
+ conn, _ = self._sock.accept()
698
+ conn.setblocking(True)
699
+ return Connection(conn.detach())
700
+
701
+ def close(self) -> None:
702
+ # Unlink while the socket is still open: an open socket pins its
703
+ # inode, so a replacement's file can't have been given the same one.
704
+ unlink_socket_if_owned(self._path, self._identity)
705
+ # On Linux only shutdown() wakes a thread blocked in accept(); macOS
706
+ # wakes it on close() and rejects shutdown() of a listening socket.
707
+ try:
708
+ self._sock.shutdown(socket.SHUT_RDWR)
709
+ except OSError:
710
+ pass
711
+ self._sock.close()
712
+
616
713
 
617
714
  def run_daemon(
618
715
  *,
@@ -695,16 +792,14 @@ def run_daemon(
695
792
  )
696
793
 
697
794
  sock_path = daemon_socket_path()
698
- if sys.platform != "win32":
699
- try:
700
- Path(sock_path).unlink(missing_ok=True)
701
- except Exception:
702
- pass
703
-
704
- listener = Listener(sock_path, family=connection_family())
795
+ if sys.platform == "win32":
796
+ listener = Listener(sock_path, family=connection_family())
797
+ else:
798
+ listener = _UnixListener(sock_path)
705
799
  logger.info("Listening on %s", sock_path)
706
800
 
707
801
  loop = asyncio.new_event_loop()
802
+ loop.set_default_executor(EmptyContextThreadPoolExecutor())
708
803
  tasks: set[asyncio.Task[Any]] = set()
709
804
 
710
805
  reaper = IdleReaper(
@@ -816,14 +911,17 @@ def run_daemon(
816
911
  # absence is how the client detects a crash.
817
912
  write_last_exit_marker(pid=os.getpid(), reason=shutdown_reason or "unknown")
818
913
 
819
- # 1. Stop accepting new connections. On Windows a blocked accept()
820
- # holds an open pipe instance that listener.close() can't release
821
- # (it only closes the queued next-instance handle), which would keep
822
- # the named pipe alive after exit — wake it with a dummy connection
823
- # so the accept thread closes it and exits. POSIX doesn't need the
824
- # wake-up (listener.close() makes accept() raise), but it's harmless
825
- # there and keeps this path exercised on every platform rather than
826
- # only on Windows CI.
914
+ # 1. Stop accepting new connections and remove the socket file (if a
915
+ # replacement daemon hasn't taken over the path). On Windows a
916
+ # blocked accept() holds an open pipe instance that listener.close()
917
+ # can't release (it only closes the queued next-instance handle),
918
+ # which would keep the named pipe alive after exit — wake it with a
919
+ # dummy connection so the accept thread closes it and exits. POSIX
920
+ # doesn't need the wake-up (listener.close() makes accept() raise),
921
+ # but it's harmless there and keeps this path exercised on every
922
+ # platform rather than only on Windows CI. If a replacement daemon
923
+ # owns the path, the wake-up reaches it instead: one extra
924
+ # connection, which only counts as activity there.
827
925
  shutting_down.set()
828
926
  try:
829
927
  Client(sock_path, family=connection_family()).close()
@@ -843,12 +941,7 @@ def run_daemon(
843
941
  registry.close_all()
844
942
  loop.close()
845
943
 
846
- # 4. Remove socket and PID file.
847
- if sys.platform != "win32":
848
- try:
849
- Path(sock_path).unlink(missing_ok=True)
850
- except Exception:
851
- pass
944
+ # 4. Remove the PID file.
852
945
  try:
853
946
  stored = pid_path.read_text().strip()
854
947
  if stored == str(os.getpid()):
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Mapping
5
6
  from pathlib import Path
6
7
 
7
8
  import cocoindex as coco
@@ -35,8 +36,13 @@ splitter = RecursiveSplitter()
35
36
  async def process_file(
36
37
  file: localfs.File,
37
38
  table: sqlite.TableTarget[CodeChunk],
39
+ language_overrides: Mapping[str, str],
38
40
  ) -> None:
39
- """Process a single file: chunk, embed, and store."""
41
+ """Process a single file: chunk, embed, and store.
42
+
43
+ ``language_overrides`` maps a file suffix (``".inc"``) to a language. It is an
44
+ argument rather than read from settings here so that it is part of the memo key.
45
+ """
40
46
  embedder = coco.use_context(EMBEDDER)
41
47
  indexing_params = coco.use_context(INDEXING_EMBED_PARAMS)
42
48
 
@@ -49,11 +55,8 @@ async def process_file(
49
55
  return
50
56
 
51
57
  suffix = file.file_path.path.suffix
52
- project_root = coco.use_context(CODEBASE_DIR)
53
- ps = load_project_settings(project_root)
54
- ext_lang_map = {f".{lo.ext}": lo.lang for lo in ps.language_overrides}
55
58
  language = (
56
- ext_lang_map.get(suffix)
59
+ language_overrides.get(suffix)
57
60
  or detect_code_language(filename=file.file_path.path.name)
58
61
  or "text"
59
62
  )
@@ -61,7 +64,7 @@ async def process_file(
61
64
  chunker_registry = coco.use_context(CHUNKER_REGISTRY)
62
65
  chunker = chunker_registry.get(suffix)
63
66
  if chunker is not None:
64
- language_override, chunks = chunker(Path(file.file_path.path), content)
67
+ language_override, chunks = chunker.fn(Path(file.file_path.path), content)
65
68
  if language_override is not None:
66
69
  language = language_override
67
70
  else:
@@ -120,6 +123,11 @@ async def indexer_main() -> None:
120
123
  path_matcher=matcher,
121
124
  )
122
125
 
126
+ language_overrides = {f".{lo.ext}": lo.lang for lo in ps.language_overrides}
123
127
  await coco.mount_each(
124
- coco.component_subpath(coco.Symbol("process_file")), process_file, files.items(), table
128
+ coco.component_subpath(coco.Symbol("process_file")),
129
+ process_file,
130
+ files.items(),
131
+ table,
132
+ language_overrides,
125
133
  )
@@ -3,16 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import contextvars
6
7
  import logging
7
8
  import sqlite3
8
- from collections.abc import AsyncIterator, Callable
9
+ import weakref
10
+ from collections.abc import AsyncIterator, Callable, Mapping
11
+ from concurrent.futures import Future, ThreadPoolExecutor
9
12
  from pathlib import Path
10
- from typing import Any
13
+ from typing import Any, ParamSpec, TypeVar
11
14
 
12
15
  import cocoindex as coco
13
16
  from cocoindex.connectors import sqlite as coco_sqlite
14
17
 
15
- from .chunking import CHUNKER_REGISTRY, ChunkerFn
18
+ from .chunking import CHUNKER_REGISTRY, LoadedChunker
16
19
  from .indexer import indexer_main
17
20
  from .protocol import (
18
21
  IndexingProgress,
@@ -45,6 +48,36 @@ from .shared import (
45
48
 
46
49
  logger = logging.getLogger(__name__)
47
50
 
51
+ _P = ParamSpec("_P")
52
+ _T = TypeVar("_T")
53
+
54
+
55
+ class _EmptyContextThreadPoolExecutor(ThreadPoolExecutor):
56
+ """A ``ThreadPoolExecutor`` whose worker threads start with an empty context.
57
+
58
+ On free-threaded Python a new thread starts with a copy of the context of the code
59
+ that started it (``sys.flags.thread_inherit_context``). The event loop's default
60
+ executor starts its workers lazily, often inside a CocoIndex component (the LocalFS
61
+ walker calls ``run_in_executor``). Such a worker keeps that component's context, which
62
+ references the project's ``Environment`` and through it the LMDB env, for as long as
63
+ the loop lives. The env then stays open after the project is closed, and loading the
64
+ project again fails with "environment already open in this program".
65
+ """
66
+
67
+ def submit(self, fn: Callable[_P, _T], /, *args: _P.args, **kwargs: _P.kwargs) -> Future[_T]:
68
+ # Worker threads are started from submit().
69
+ return contextvars.Context().run(super().submit, fn, *args, **kwargs)
70
+
71
+
72
+ # Loops whose default executor is already an _EmptyContextThreadPoolExecutor.
73
+ _loops_with_empty_context_executor: weakref.WeakSet[asyncio.AbstractEventLoop] = weakref.WeakSet()
74
+
75
+
76
+ def _use_empty_context_default_executor(loop: asyncio.AbstractEventLoop) -> None:
77
+ if loop not in _loops_with_empty_context_executor:
78
+ loop.set_default_executor(_EmptyContextThreadPoolExecutor(thread_name_prefix="asyncio"))
79
+ _loops_with_empty_context_executor.add(loop)
80
+
48
81
 
49
82
  class Project:
50
83
  _env: coco.Environment
@@ -281,7 +314,7 @@ class Project:
281
314
  embedder: Embedder,
282
315
  indexing_params: dict[str, Any],
283
316
  query_params: dict[str, Any],
284
- chunker_registry: dict[str, ChunkerFn] | None = None,
317
+ chunker_registry: Mapping[str, LoadedChunker] | None = None,
285
318
  clear_mps_cache_after_index: bool = False,
286
319
  ) -> Project:
287
320
  """Create a project with explicit embedder and per-call params.
@@ -299,7 +332,7 @@ class Project:
299
332
  query_params: Extra kwargs spread into ``embedder.embed()`` for the
300
333
  query side.
301
334
  chunker_registry: Optional mapping of file suffix (e.g. ``".toml"``)
302
- to a ``ChunkerFn``. When a suffix matches, the registered
335
+ to a ``LoadedChunker``. When a suffix matches, the registered
303
336
  chunker is called instead of the built-in splitter.
304
337
  clear_mps_cache_after_index: Whether to release unused MPS allocator
305
338
  memory in CocoIndex's GPU subprocess after each index run.
@@ -315,6 +348,10 @@ class Project:
315
348
 
316
349
  settings = coco.Settings.from_env(cocoindex_db)
317
350
 
351
+ # The environment runs its components on the running loop. Keep that loop's
352
+ # executor threads from holding on to the environment once this project is closed.
353
+ _use_empty_context_default_executor(asyncio.get_running_loop())
354
+
318
355
  context = coco.ContextProvider()
319
356
  context.provide(CODEBASE_DIR, project_root)
320
357
  context.provide(SQLITE_DB, coco_sqlite.connect(str(target_sqlite_db), load_vec=True))
@@ -18,7 +18,7 @@ from mcp.server.mcpserver import MCPServer
18
18
  from pydantic import BaseModel, Field
19
19
 
20
20
  from ._version import __version__
21
- from .settings import DaemonSettings, load_user_settings
21
+ from .settings import DaemonSettings, load_user_settings, user_settings_root_note
22
22
 
23
23
  _MCP_INSTRUCTIONS = (
24
24
  "Code search and codebase understanding tools."
@@ -56,6 +56,13 @@ class SearchResultModel(BaseModel):
56
56
  message: str | None = None
57
57
 
58
58
 
59
+ def _with_note(message: str | None, note: str | None) -> str | None:
60
+ """Append *note* to a result message; MCP clients hide the server's stderr."""
61
+ if note is None:
62
+ return message
63
+ return f"{message}\nNote: {note}" if message else f"Note: {note}"
64
+
65
+
59
66
  # === Daemon-backed MCP server factory ===
60
67
 
61
68
 
@@ -125,7 +132,9 @@ def create_mcp_server(project_root: str) -> MCPServer:
125
132
  from . import client as _client
126
133
 
127
134
  loop = asyncio.get_event_loop()
135
+ note: str | None = None
128
136
  try:
137
+ note = user_settings_root_note(Path(project_root), Path.cwd())
129
138
  if refresh_index:
130
139
  await loop.run_in_executor(None, lambda: _client.index(project_root))
131
140
  resp = await loop.run_in_executor(
@@ -154,10 +163,12 @@ def create_mcp_server(project_root: str) -> MCPServer:
154
163
  ],
155
164
  total_returned=resp.total_returned,
156
165
  offset=resp.offset,
157
- message=resp.message,
166
+ message=_with_note(resp.message, note),
158
167
  )
159
168
  except Exception as e:
160
- return SearchResultModel(success=False, message=f"Query failed: {e!s}")
169
+ return SearchResultModel(
170
+ success=False, message=_with_note(f"Query failed: {e!s}", note)
171
+ )
161
172
 
162
173
  return mcp
163
174
 
@@ -225,10 +236,12 @@ def main() -> None:
225
236
  Auto-detects/creates settings from env vars, then delegates to daemon.
226
237
  """
227
238
  import argparse
239
+ import sys
228
240
 
229
241
  from .settings import (
230
242
  EmbeddingSettings,
231
243
  LanguageOverride,
244
+ auto_init_refusal,
232
245
  default_project_settings,
233
246
  default_user_settings,
234
247
  find_legacy_project_root,
@@ -251,12 +264,14 @@ def main() -> None:
251
264
  # --- Discover project root ---
252
265
  cwd = Path.cwd()
253
266
  project_root = find_project_root(cwd)
267
+ root_from_env = False
254
268
 
255
269
  if project_root is None:
256
270
  # Try env var
257
271
  env_root = os.environ.get("COCOINDEX_CODE_ROOT_PATH")
258
272
  if env_root:
259
273
  project_root = Path(env_root).resolve()
274
+ root_from_env = True
260
275
  else:
261
276
  # Use marker-based discovery
262
277
  legacy_root = find_legacy_project_root(cwd)
@@ -265,6 +280,14 @@ def main() -> None:
265
280
  # --- Auto-create project settings if needed ---
266
281
  proj_settings_file = project_settings_path(project_root)
267
282
  if not proj_settings_file.is_file():
283
+ # COCOINDEX_CODE_ROOT_PATH is a deliberate choice, like `ccc init`: only a
284
+ # root found by discovery is refused.
285
+ refusal = None if root_from_env else auto_init_refusal(project_root)
286
+ if refusal is not None:
287
+ sys.exit(
288
+ f"Error: {refusal} To serve a project from here, set"
289
+ " COCOINDEX_CODE_ROOT_PATH to its directory."
290
+ )
268
291
  ps = default_project_settings()
269
292
 
270
293
  # Migrate COCOINDEX_CODE_EXCLUDED_PATTERNS
@@ -320,8 +343,6 @@ def main() -> None:
320
343
  from .protocol import IndexingProgress
321
344
 
322
345
  if args.command == "index":
323
- import sys
324
-
325
346
  from rich.console import Console
326
347
  from rich.live import Live
327
348
  from rich.spinner import Spinner
@@ -354,6 +354,54 @@ def project_settings_path(project_root: Path) -> Path:
354
354
  return project_root / _SETTINGS_DIR_NAME / _SETTINGS_FILE_NAME
355
355
 
356
356
 
357
+ def is_user_settings_root(directory: Path) -> bool:
358
+ """Return whether *directory*'s ``.cocoindex_code`` is the user settings dir.
359
+
360
+ That is ``$HOME`` by default, ``/x`` with ``COCOINDEX_CODE_DIR=/x/.cocoindex_code``
361
+ (the Docker image's layout), and no directory with ``COCOINDEX_CODE_DIR=/x/ccc``.
362
+ A project there covers every directory below it, so ccc never creates one
363
+ automatically; ``ccc init`` run there by hand still does.
364
+ """
365
+ return (directory / _SETTINGS_DIR_NAME).resolve() == user_settings_dir().resolve()
366
+
367
+
368
+ def auto_init_refusal(directory: Path) -> str | None:
369
+ """Return why *directory* must not be initialized automatically, or ``None``."""
370
+ if not is_user_settings_root(directory):
371
+ return None
372
+ shown = format_path_for_display(directory)
373
+ return (
374
+ f"{shown} has no project settings, and ccc does not create them there"
375
+ f" automatically: its {_SETTINGS_DIR_NAME} directory is ccc's user settings"
376
+ " directory, so a project there would cover every directory below it."
377
+ " Run ccc from inside a project instead. To index all of"
378
+ f" {shown}, run `ccc init` there."
379
+ )
380
+
381
+
382
+ def user_settings_root_note(root: Path, start: Path) -> str | None:
383
+ """Explain why a repo containing *start* resolves to the user settings root.
384
+
385
+ Such a project is usually left over from an older ccc that auto-initialized
386
+ ``$HOME``: every repo below it without settings of its own resolves to it.
387
+ Returns ``None`` unless *root* is the user settings root and *start* lies in
388
+ a repo below it.
389
+ """
390
+ if not is_user_settings_root(root):
391
+ return None
392
+ repo = find_parent_with_marker(start)
393
+ if repo is None or repo == root:
394
+ return None
395
+ shown_root = format_path_for_display(root)
396
+ shown_repo = format_path_for_display(repo)
397
+ return (
398
+ f"{shown_repo} has no ccc project of its own, so the project in use is"
399
+ f" {shown_root}, whose .cocoindex_code directory is also ccc's user settings"
400
+ f" directory. If {shown_root} is not meant to be a project, run"
401
+ f" `ccc reset --all` there, then `ccc init` in {shown_repo}."
402
+ )
403
+
404
+
357
405
  def find_project_root(start: Path) -> Path | None:
358
406
  """Walk up from *start* looking for ``.cocoindex_code/settings.yml``.
359
407
 
@@ -37,7 +37,8 @@ Embedder = Union[
37
37
  EMBEDDER = coco.ContextKey[Embedder]("embedder", detect_change=True)
38
38
  SQLITE_DB = coco.ContextKey[sqlite.ManagedConnection]("index_db")
39
39
  CODEBASE_DIR = coco.ContextKey[pathlib.Path]("codebase")
40
- INDEXING_EMBED_PARAMS = coco.ContextKey[dict[str, Any]]("indexing_embed_params")
40
+ INDEXING_EMBED_PARAMS = coco.ContextKey[dict[str, Any]]("indexing_embed_params", detect_change=True)
41
+ # Read only at query time, outside any memoized function: nothing to invalidate.
41
42
  QUERY_EMBED_PARAMS = coco.ContextKey[dict[str, Any]]("query_embed_params")
42
43
 
43
44
 
@@ -1,29 +0,0 @@
1
- """Public API for writing custom chunkers.
2
-
3
- Example usage::
4
-
5
- from pathlib import Path
6
- from cocoindex_code.chunking import Chunk, ChunkerFn, TextPosition
7
-
8
- def my_chunker(path: Path, content: str) -> tuple[str | None, list[Chunk]]:
9
- pos = TextPosition(byte_offset=0, char_offset=0, line=1, column=0)
10
- return "mylang", [Chunk(text=content, start=pos, end=pos)]
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- import pathlib as _pathlib
16
- from collections.abc import Callable as _Callable
17
-
18
- import cocoindex as _coco
19
- from cocoindex.resources.chunk import Chunk, TextPosition
20
-
21
- # Callable alias (not Protocol) — consistent with codebase style.
22
- # language_override=None keeps the language detected by detect_code_language.
23
- # path is not resolved (no syscall); call path.resolve() inside the chunker if needed.
24
- ChunkerFn = _Callable[[_pathlib.Path, str], tuple[str | None, list[Chunk]]]
25
-
26
- # tracked=False: callables are not fingerprint-able; daemon restart re-indexes anyway.
27
- CHUNKER_REGISTRY = _coco.ContextKey[dict[str, ChunkerFn]]("chunker_registry")
28
-
29
- __all__ = ["Chunk", "ChunkerFn", "CHUNKER_REGISTRY", "TextPosition"]
File without changes