codepulse-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
codepulse/config.py ADDED
@@ -0,0 +1,475 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import tomllib
7
+ from typing import Any, Optional
8
+ from pydantic import BaseModel, Field, model_validator
9
+
10
+ from codepulse.data_location import default_database_url
11
+
12
+
13
+ class CodePulseConfig(BaseModel):
14
+ """
15
+ Central configuration model for CodePulse.
16
+
17
+ Precedence order:
18
+ CLI flags > Environment variables (CODEPULSE_*) > codepulse.toml > Built-in defaults
19
+ """
20
+
21
+ # Storage & Database
22
+ db_url: str = Field(
23
+ default_factory=default_database_url,
24
+ description=(
25
+ "Database connection URL (e.g., sqlite:///codepulse.db). Unset, it is ONE database per user in the "
26
+ "operating system's per-user data directory (see codepulse.data_location), or the codepulse.db already "
27
+ "in the working directory if there is one (the previous default)."
28
+ ),
29
+ )
30
+
31
+ # Embedding Settings
32
+ embedding_model: str = Field(
33
+ default="all-MiniLM-L6-v2",
34
+ description="SentenceTransformers model name for dense code embeddings",
35
+ )
36
+ embedding_device: str = Field(
37
+ default="cpu",
38
+ description="Inference compute device for embedding model (cpu, cuda, mps)",
39
+ )
40
+ embedding_dimension: int = Field(
41
+ default=384,
42
+ description="Dense vector embedding dimension",
43
+ )
44
+
45
+ # Chunker Settings
46
+ chunk_max_tokens: int = Field(
47
+ default=300,
48
+ description="Approximate max tokens per code chunk",
49
+ )
50
+ chunk_overlap_lines: int = Field(
51
+ default=3,
52
+ description="Line overlap between sequential body chunks",
53
+ )
54
+
55
+ # Search & Ranking Settings
56
+ weight_keyword: float = Field(
57
+ default=0.40,
58
+ description="Weight for BM25 keyword score in hybrid search fusion",
59
+ )
60
+ weight_vector: float = Field(
61
+ default=0.40,
62
+ description="Weight for vector similarity score in hybrid search fusion",
63
+ )
64
+ weight_graph: float = Field(
65
+ default=0.20,
66
+ description="Weight for call graph centrality score in hybrid search fusion",
67
+ )
68
+ exact_match_boost: float = Field(
69
+ default=0.15,
70
+ description="Score boost added when query exactly matches symbol name",
71
+ )
72
+ bm25_k1: float = Field(
73
+ default=1.5,
74
+ description="BM25 k1 term frequency saturation parameter",
75
+ )
76
+ bm25_b: float = Field(
77
+ default=0.75,
78
+ description="BM25 b document length normalization parameter",
79
+ )
80
+ rrf_k: int = Field(
81
+ default=60,
82
+ description=(
83
+ "Reciprocal Rank Fusion (RRF) smoothing constant (roadmap §37 "
84
+ "item 2.3: RRF fusion path wired to be selectable via API "
85
+ "search_type/config). Formula: RRF_score(d) = sum(1 / (k + "
86
+ "rank_i(d))). 60 is the conventional default used in the "
87
+ "original RRF paper and most implementations -- not a "
88
+ "benchmarked figure for this project specifically; tune via "
89
+ "config/env for a given workload."
90
+ ),
91
+ )
92
+ vector_floor_rank: int = Field(
93
+ default=20,
94
+ ge=0,
95
+ description=(
96
+ "How the semantic score is scaled before hybrid fusion. The "
97
+ "lexical score is relative (the best keyword hit is always 1.0) "
98
+ "but a raw cosine similarity is absolute and sits in a narrow "
99
+ "band, so the two were weighted as if on one scale. The vector "
100
+ "score is now (sim - floor) / (top - floor), clipped to 0..1, "
101
+ "where top is the best similarity for the query and floor is the "
102
+ "similarity of the hit at this rank. It does not depend on how "
103
+ "many hits were fetched beyond this rank, so scores never change "
104
+ "with the requested limit. 0 restores the old absolute "
105
+ "(cosine + 1) / 2 scaling. 20 was chosen on paraphrase queries "
106
+ "over two codebases (FUTURE_CHANGES item 20); not a universal "
107
+ "constant."
108
+ ),
109
+ )
110
+
111
+ # Server / API Settings
112
+ server_host: str = Field(
113
+ default="127.0.0.1",
114
+ description="API server bind host",
115
+ )
116
+ server_port: int = Field(
117
+ default=8000,
118
+ description="API server bind port",
119
+ )
120
+ cors_origins: list[str] = Field(
121
+ default_factory=list,
122
+ description=(
123
+ "Allowed CORS origin list. Empty by default (deny all cross-origin "
124
+ "browser requests) -- Phase 0.9 replaces the prior '*' wildcard "
125
+ "default, which combined with allow_credentials=True was both an "
126
+ "unconsidered default and, per the CORS spec, a combination "
127
+ "browsers reject outright for credentialed requests. Set explicit "
128
+ "origin(s) via codepulse.toml's [server] section or the "
129
+ "CODEPULSE_CORS_ORIGINS env var for deployments that need "
130
+ "browser-based cross-origin access (e.g. a future Web UI)."
131
+ ),
132
+ )
133
+ cors_allow_credentials: bool = Field(
134
+ default=False,
135
+ description="Whether to allow credentials in CORS headers. Defaults to False (secure by default).",
136
+ )
137
+
138
+ # Rate Limiting (Phase 1.4)
139
+ rate_limit_enabled: bool = Field(
140
+ default=True,
141
+ description="Whether the in-process token-bucket rate-limiting middleware is active.",
142
+ )
143
+ rate_limit_requests_per_second: float = Field(
144
+ default=5.0,
145
+ description=(
146
+ "In-process token-bucket rate limiter refill rate (tokens/second) "
147
+ "per client identity (the raw API key header value, or the "
148
+ "client's IP address as a fallback for unauthenticated "
149
+ "requests). A reasonable starting default for local-first, "
150
+ "single-developer use -- NOT a benchmarked/measured figure (see "
151
+ "roadmap §19's own caution against presenting unvalidated "
152
+ "numbers as fact); tune via config/env for a given workload."
153
+ ),
154
+ )
155
+ rate_limit_burst: int = Field(
156
+ default=20,
157
+ description="Token-bucket capacity (max burst size) for the in-process rate limiter.",
158
+ )
159
+
160
+ # Ingestion Safety (Phase 1.5)
161
+ ingestion_allowed_roots: list[str] = Field(
162
+ default_factory=list,
163
+ description=(
164
+ "Allow-list of root directories local-path ingestion (POST "
165
+ "/v1/repositories/index) is restricted to. Empty by default -- "
166
+ "meaning UNRESTRICTED -- deliberately preserving the roadmap's "
167
+ "own local-first default experience ('codepulse index .' works "
168
+ "with zero configuration): this endpoint acts on behalf of the "
169
+ "local user who already has filesystem access to their own "
170
+ "machine, so there is nothing to protect against until this API "
171
+ "is exposed beyond localhost with a key untrusted parties might "
172
+ "hold (roadmap §15's stated concern for this setting). Configure "
173
+ "one or more roots via codepulse.toml/the "
174
+ "CODEPULSE_INGESTION_ALLOWED_ROOTS env var to restrict ingestion "
175
+ "for a server/team deployment; when set, a request path that "
176
+ "resolves (after following symlinks and collapsing '..') outside "
177
+ "every configured root is rejected."
178
+ ),
179
+ )
180
+ ingestion_max_files_per_repo: int = Field(
181
+ default=50000,
182
+ description=(
183
+ "Maximum number of discoverable files a single indexing run will "
184
+ "accept before refusing to proceed (roadmap §15: 'max files per "
185
+ "repo'). A generous starting ceiling, NOT a benchmarked figure -- "
186
+ "tune via config for your workload."
187
+ ),
188
+ )
189
+ ingestion_max_file_size_bytes: int = Field(
190
+ default=5_000_000,
191
+ description=(
192
+ "Maximum size, in bytes, of a single file that will be read and "
193
+ "parsed during indexing (roadmap §15: 'max file size parsed'). A "
194
+ "larger file is skipped (logged, not fatal to the run -- the "
195
+ "same per-file error-tolerance already established for "
196
+ "malformed/unparseable files), not truncated. A reasonable "
197
+ "starting default, NOT a benchmarked figure."
198
+ ),
199
+ )
200
+
201
+ # Incremental Indexing (Phase 3.1)
202
+ incremental_indexing_enabled: bool = Field(
203
+ default=True,
204
+ description=(
205
+ "Whether IndexingEngine.index_directory() attempts git-diff-based "
206
+ "incremental change detection (roadmap §37 item 3.1) before "
207
+ "falling back to a full re-index. Every §9 fallback condition "
208
+ "(not a git repo, no prior snapshot, embedding model changed, "
209
+ "too large a fraction of files changed) still applies even when "
210
+ "this is True; setting it False unconditionally forces a full "
211
+ "re-index every time, matching this project's pre-Phase-3.1 "
212
+ "behavior exactly."
213
+ ),
214
+ )
215
+ incremental_max_changed_file_fraction: float = Field(
216
+ default=0.5,
217
+ description=(
218
+ "If more than this fraction of a repository's previously-"
219
+ "indexed files changed since the last snapshot, a full "
220
+ "re-index is used instead of an incremental one (roadmap §9: "
221
+ "'more than some threshold (e.g., 50%) of files changed at "
222
+ "once (rebase, mass reformat) -- in which case a full "
223
+ "re-index is actually cheaper than incremental bookkeeping'). "
224
+ "0.5 is the roadmap's own literal example figure, not an "
225
+ "independently benchmarked one."
226
+ ),
227
+ )
228
+
229
+ model_config = {
230
+ "extra": "ignore",
231
+ }
232
+
233
+ @model_validator(mode="after")
234
+ def _validate_cors_policy(self) -> "CodePulseConfig":
235
+ """
236
+ Permanently rejects the invalid/insecure combination of a wildcard
237
+ CORS origin with credentials enabled (Phase 0.9) -- browsers
238
+ themselves refuse to honor credentialed requests against a '*'
239
+ origin, so allowing this combination to be configured at all would
240
+ only silently produce a broken (and misleadingly permissive-looking)
241
+ policy. This guards the setting itself, not just its default, so a
242
+ future config/env override can't reintroduce it unnoticed.
243
+ """
244
+ if self.cors_allow_credentials and "*" in self.cors_origins:
245
+ raise ValueError(
246
+ "Invalid CORS configuration: cors_allow_credentials=True cannot be "
247
+ "combined with a wildcard ('*') in cors_origins -- browsers reject "
248
+ "this combination for credentialed requests. Configure specific "
249
+ "origin(s) in cors_origins, or set cors_allow_credentials=False."
250
+ )
251
+ return self
252
+
253
+
254
+ def _normalize_toml_dict(raw: dict[str, Any]) -> dict[str, Any]:
255
+ """
256
+ Normalizes a parsed TOML dictionary into CodePulseConfig field keys.
257
+ Supports both flat keys (e.g. `db_url = "..."`) and sectioned tables
258
+ (e.g. `[storage] db_url = "..."`).
259
+ """
260
+ normalized: dict[str, Any] = {}
261
+
262
+ # 1. Flat top-level keys
263
+ for k, v in raw.items():
264
+ if not isinstance(v, dict):
265
+ normalized[k] = v
266
+
267
+ # 2. Sectioned tables
268
+ storage = raw.get("storage", {})
269
+ if isinstance(storage, dict):
270
+ if "db_url" in storage:
271
+ normalized["db_url"] = storage["db_url"]
272
+
273
+ embedding = raw.get("embedding", {})
274
+ if isinstance(embedding, dict):
275
+ if "model" in embedding or "embedding_model" in embedding or "model_name" in embedding:
276
+ normalized["embedding_model"] = (
277
+ embedding.get("embedding_model")
278
+ or embedding.get("model_name")
279
+ or embedding.get("model")
280
+ )
281
+ if "device" in embedding or "embedding_device" in embedding:
282
+ normalized["embedding_device"] = embedding.get("embedding_device") or embedding.get("device")
283
+ if "dimension" in embedding or "embedding_dimension" in embedding:
284
+ normalized["embedding_dimension"] = (
285
+ embedding.get("embedding_dimension") or embedding.get("dimension")
286
+ )
287
+
288
+ chunker = raw.get("chunker", {})
289
+ if isinstance(chunker, dict):
290
+ if "max_tokens" in chunker or "chunk_max_tokens" in chunker:
291
+ normalized["chunk_max_tokens"] = chunker.get("chunk_max_tokens") or chunker.get("max_tokens")
292
+ if "overlap_lines" in chunker or "chunk_overlap_lines" in chunker:
293
+ normalized["chunk_overlap_lines"] = (
294
+ chunker.get("chunk_overlap_lines") or chunker.get("overlap_lines")
295
+ )
296
+
297
+ search = raw.get("search", {})
298
+ if isinstance(search, dict):
299
+ for key in (
300
+ "weight_keyword",
301
+ "weight_vector",
302
+ "weight_graph",
303
+ "exact_match_boost",
304
+ "bm25_k1",
305
+ "bm25_b",
306
+ "rrf_k",
307
+ "vector_floor_rank",
308
+ ):
309
+ if key in search:
310
+ normalized[key] = search[key]
311
+
312
+ server = raw.get("server", {})
313
+ if isinstance(server, dict):
314
+ if "host" in server or "server_host" in server:
315
+ normalized["server_host"] = server.get("server_host") or server.get("host")
316
+ if "port" in server or "server_port" in server:
317
+ normalized["server_port"] = server.get("server_port") or server.get("port")
318
+ if "cors_origins" in server:
319
+ normalized["cors_origins"] = server["cors_origins"]
320
+ if "cors_allow_credentials" in server:
321
+ normalized["cors_allow_credentials"] = server["cors_allow_credentials"]
322
+
323
+ return normalized
324
+
325
+
326
+ # Mapping of environment variables to CodePulseConfig field names
327
+ _ENV_VAR_MAPPINGS: dict[str, str] = {
328
+ "CODEPULSE_DB_URL": "db_url",
329
+ "CODEPULSE_EMBEDDING_MODEL": "embedding_model",
330
+ "CODEPULSE_MODEL_NAME": "embedding_model",
331
+ "CODEPULSE_EMBEDDING_DEVICE": "embedding_device",
332
+ "CODEPULSE_DEVICE": "embedding_device",
333
+ "CODEPULSE_EMBEDDING_DIMENSION": "embedding_dimension",
334
+ "CODEPULSE_CHUNK_MAX_TOKENS": "chunk_max_tokens",
335
+ "CODEPULSE_CHUNK_OVERLAP_LINES": "chunk_overlap_lines",
336
+ "CODEPULSE_WEIGHT_KEYWORD": "weight_keyword",
337
+ "CODEPULSE_WEIGHT_VECTOR": "weight_vector",
338
+ "CODEPULSE_WEIGHT_GRAPH": "weight_graph",
339
+ "CODEPULSE_EXACT_MATCH_BOOST": "exact_match_boost",
340
+ "CODEPULSE_BM25_K1": "bm25_k1",
341
+ "CODEPULSE_BM25_B": "bm25_b",
342
+ "CODEPULSE_RRF_K": "rrf_k",
343
+ "CODEPULSE_VECTOR_FLOOR_RANK": "vector_floor_rank",
344
+ "CODEPULSE_SERVER_HOST": "server_host",
345
+ "CODEPULSE_HOST": "server_host",
346
+ "CODEPULSE_SERVER_PORT": "server_port",
347
+ "CODEPULSE_PORT": "server_port",
348
+ "CODEPULSE_CORS_ORIGINS": "cors_origins",
349
+ "CODEPULSE_CORS_ALLOW_CREDENTIALS": "cors_allow_credentials",
350
+ "CODEPULSE_RATE_LIMIT_ENABLED": "rate_limit_enabled",
351
+ "CODEPULSE_RATE_LIMIT_REQUESTS_PER_SECOND": "rate_limit_requests_per_second",
352
+ "CODEPULSE_RATE_LIMIT_BURST": "rate_limit_burst",
353
+ "CODEPULSE_INGESTION_ALLOWED_ROOTS": "ingestion_allowed_roots",
354
+ "CODEPULSE_INGESTION_MAX_FILES_PER_REPO": "ingestion_max_files_per_repo",
355
+ "CODEPULSE_INGESTION_MAX_FILE_SIZE_BYTES": "ingestion_max_file_size_bytes",
356
+ "CODEPULSE_INCREMENTAL_INDEXING_ENABLED": "incremental_indexing_enabled",
357
+ "CODEPULSE_INCREMENTAL_MAX_CHANGED_FILE_FRACTION": "incremental_max_changed_file_fraction",
358
+ }
359
+
360
+
361
+ def _coerce_env_val(field_name: str, raw_val: str) -> Any:
362
+ """Coerces a string environment variable value into its target field type."""
363
+ field_info = CodePulseConfig.model_fields.get(field_name)
364
+ if not field_info:
365
+ return raw_val
366
+
367
+ annotation = field_info.annotation
368
+
369
+ if annotation is int:
370
+ return int(raw_val)
371
+ elif annotation is float:
372
+ return float(raw_val)
373
+ elif annotation is bool:
374
+ return raw_val.strip().lower() in ("true", "1", "yes", "on")
375
+ elif annotation is list[str] or getattr(annotation, "__origin__", None) is list:
376
+ val_str = raw_val.strip()
377
+ if val_str.startswith("[") and val_str.endswith("]"):
378
+ try:
379
+ return json.loads(val_str)
380
+ except Exception:
381
+ pass
382
+ return [item.strip() for item in val_str.split(",") if item.strip()]
383
+
384
+ return raw_val
385
+
386
+
387
+ def _load_env_overrides() -> dict[str, Any]:
388
+ """Extracts all matching CODEPULSE_* environment variables."""
389
+ overrides: dict[str, Any] = {}
390
+ for env_key, field_name in _ENV_VAR_MAPPINGS.items():
391
+ if env_key in os.environ:
392
+ val = os.environ[env_key]
393
+ overrides[field_name] = _coerce_env_val(field_name, val)
394
+ return overrides
395
+
396
+
397
+ def load_config(
398
+ config_file: str | Path | None = None,
399
+ cli_overrides: dict[str, Any] | None = None,
400
+ ) -> CodePulseConfig:
401
+ """
402
+ Loads CodePulse configuration following the strict precedence rule:
403
+
404
+ CLI flags > Environment variables > codepulse.toml > Built-in defaults
405
+ """
406
+ # 1. Start with empty dict (will use model defaults)
407
+ merged: dict[str, Any] = {}
408
+
409
+ # 2. Locate and load codepulse.toml if present
410
+ toml_path: Optional[Path] = None
411
+ if config_file:
412
+ p = Path(config_file)
413
+ if p.is_file():
414
+ toml_path = p
415
+ elif "CODEPULSE_CONFIG" in os.environ:
416
+ p = Path(os.environ["CODEPULSE_CONFIG"])
417
+ if p.is_file():
418
+ toml_path = p
419
+ else:
420
+ # Check standard default locations
421
+ cwd_toml = Path("codepulse.toml")
422
+ if cwd_toml.is_file():
423
+ toml_path = cwd_toml
424
+
425
+ if toml_path and toml_path.is_file():
426
+ try:
427
+ with open(toml_path, "rb") as f:
428
+ raw_toml = tomllib.load(f)
429
+ toml_data = _normalize_toml_dict(raw_toml)
430
+ merged.update(toml_data)
431
+ except Exception as e:
432
+ import logging
433
+ logging.getLogger(__name__).warning("Failed to parse TOML config at %s: %s", toml_path, e)
434
+
435
+ # 3. Environment variable overrides (higher precedence than TOML)
436
+ env_overrides = _load_env_overrides()
437
+ merged.update(env_overrides)
438
+
439
+ # 4. CLI overrides (highest precedence)
440
+ if cli_overrides:
441
+ for k, v in cli_overrides.items():
442
+ if v is not None:
443
+ merged[k] = v
444
+
445
+ return CodePulseConfig(**merged)
446
+
447
+
448
+ _GLOBAL_SETTINGS: Optional[CodePulseConfig] = None
449
+
450
+
451
+ def get_settings(
452
+ config_file: str | Path | None = None,
453
+ cli_overrides: dict[str, Any] | None = None,
454
+ reload: bool = False,
455
+ ) -> CodePulseConfig:
456
+ """
457
+ Returns the active CodePulse configuration singleton.
458
+ If `reload=True` or explicit overrides are provided, reloads the configuration.
459
+ """
460
+ global _GLOBAL_SETTINGS
461
+ if _GLOBAL_SETTINGS is None or reload or config_file is not None or cli_overrides is not None:
462
+ _GLOBAL_SETTINGS = load_config(config_file=config_file, cli_overrides=cli_overrides)
463
+ return _GLOBAL_SETTINGS
464
+
465
+
466
+ def set_settings(config: CodePulseConfig) -> None:
467
+ """Explicitly sets the global configuration instance (primarily for testing)."""
468
+ global _GLOBAL_SETTINGS
469
+ _GLOBAL_SETTINGS = config
470
+
471
+
472
+ def reset_settings() -> None:
473
+ """Resets the global configuration instance to uninitialized state."""
474
+ global _GLOBAL_SETTINGS
475
+ _GLOBAL_SETTINGS = None
@@ -0,0 +1,78 @@
1
+ """
2
+ Where CodePulse keeps its database by default.
3
+
4
+ The default used to be `sqlite:///codepulse.db`, resolved against the CURRENT WORKING DIRECTORY. Anything that starts
5
+ the server or the CLI from inside a project (the VS Code extension does exactly that) therefore dropped a
6
+ `codepulse.db` into that project, one database per folder you ever ran it in, and a CLI run from another folder used
7
+ yet another. Now there is ONE database per user, in the operating system's per-user data directory, with two guards:
8
+
9
+ - an explicit setting always wins (CLI flag, CODEPULSE_DB_URL, codepulse.toml: unchanged precedence);
10
+ - if the working directory already holds a `codepulse.db`, that one is kept (the previous default), so an existing
11
+ setup keeps working exactly as before and nobody's data is silently orphaned.
12
+
13
+ `CODEPULSE_DATA_DIR` overrides the directory (containers, tests, portable installs).
14
+
15
+ Pure functions (platform, environment, home and working directory are injectable) with no side effects, except
16
+ `ensure_database_directory`, which creates ONLY the per-user data directory, never a directory the user typed.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+ from typing import Mapping
24
+
25
+ DATABASE_FILENAME = "codepulse.db"
26
+ LEGACY_DATABASE_URL = f"sqlite:///{DATABASE_FILENAME}"
27
+ _SQLITE_PREFIX = "sqlite:///"
28
+
29
+
30
+ def user_data_dir(
31
+ *, platform: str | None = None, env: Mapping[str, str] | None = None, home: Path | None = None
32
+ ) -> Path:
33
+ """The per-user directory CodePulse keeps its data in (not created here)."""
34
+ env = os.environ if env is None else env
35
+ platform = sys.platform if platform is None else platform
36
+ home = Path.home() if home is None else home
37
+
38
+ explicit = env.get("CODEPULSE_DATA_DIR")
39
+ if explicit:
40
+ return Path(explicit)
41
+ if platform == "win32":
42
+ base = Path(env["LOCALAPPDATA"]) if env.get("LOCALAPPDATA") else home / "AppData" / "Local"
43
+ return base / "CodePulse"
44
+ if platform == "darwin":
45
+ return home / "Library" / "Application Support" / "CodePulse"
46
+ base = Path(env["XDG_DATA_HOME"]) if env.get("XDG_DATA_HOME") else home / ".local" / "share"
47
+ return base / "codepulse"
48
+
49
+
50
+ def default_database_url(
51
+ *,
52
+ cwd: Path | None = None,
53
+ platform: str | None = None,
54
+ env: Mapping[str, str] | None = None,
55
+ home: Path | None = None,
56
+ ) -> str:
57
+ """The database URL used when nothing was configured (see the module docstring)."""
58
+ cwd = Path.cwd() if cwd is None else cwd
59
+ if (cwd / DATABASE_FILENAME).is_file():
60
+ return LEGACY_DATABASE_URL
61
+ return _SQLITE_PREFIX + (user_data_dir(platform=platform, env=env, home=home) / DATABASE_FILENAME).as_posix()
62
+
63
+
64
+ def ensure_database_directory(db_url: str) -> None:
65
+ """
66
+ Creates the per-user data directory if `db_url` is a SQLite file inside it and it does not exist yet.
67
+ Any other missing directory is left alone: a path the user typed that does not exist is still an error.
68
+ """
69
+ if not db_url.startswith(_SQLITE_PREFIX):
70
+ return
71
+ raw = db_url[len(_SQLITE_PREFIX):].split("?", 1)[0]
72
+ if raw in ("", ":memory:"):
73
+ return
74
+ parent = Path(os.path.abspath(raw)).parent
75
+ if parent.exists():
76
+ return
77
+ if os.path.normcase(os.path.abspath(parent)) == os.path.normcase(os.path.abspath(user_data_dir())):
78
+ parent.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+ from codepulse.models import Symbol
5
+
6
+
7
+ class CodeChunk:
8
+ """Represents a chunk of code prepared for embedding."""
9
+
10
+ def __init__(
11
+ self,
12
+ symbol_name: str,
13
+ file_path: str,
14
+ chunk_type: str, # 'signature_doc', 'body_chunk', 'full'
15
+ content: str,
16
+ start_line: int,
17
+ end_line: int,
18
+ chunk_index: int = 0,
19
+ ) -> None:
20
+ self.symbol_name = symbol_name
21
+ self.file_path = file_path
22
+ self.chunk_type = chunk_type
23
+ self.content = content
24
+ self.start_line = start_line
25
+ self.end_line = end_line
26
+ self.chunk_index = chunk_index
27
+
28
+ def __repr__(self) -> str:
29
+ return f"<CodeChunk {self.symbol_name} ({self.chunk_type}) lines={self.start_line}-{self.end_line}>"
30
+
31
+
32
+ class SemanticChunker:
33
+ """
34
+ AST-aware semantic chunker for code symbols.
35
+
36
+ Splits symbols based on logical boundaries (signature + docstring vs body chunks)
37
+ rather than naive arbitrary character/token splits.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ max_tokens: int | None = None,
43
+ chunk_overlap_lines: int | None = None,
44
+ ) -> None:
45
+ from codepulse.config import get_settings
46
+ settings = get_settings()
47
+ self.max_tokens = max_tokens if max_tokens is not None else settings.chunk_max_tokens
48
+ self.chunk_overlap_lines = (
49
+ chunk_overlap_lines if chunk_overlap_lines is not None else settings.chunk_overlap_lines
50
+ )
51
+
52
+ def chunk_symbol(self, symbol: Symbol) -> list[CodeChunk]:
53
+ """
54
+ Creates semantic chunks for an extracted code Symbol.
55
+
56
+ Strategy:
57
+ 1. Always create a high-signal header chunk: Signature + Docstring.
58
+ 2. If the body is short (< max_tokens roughly ~ 1200 chars), create a 'full' chunk.
59
+ 3. If the body is large, create overlapping window chunks of the body.
60
+ """
61
+ chunks: list[CodeChunk] = []
62
+
63
+ # 1. Signature + Docstring Chunk (highest semantic signal for query matching)
64
+ header_parts = []
65
+ if symbol.signature:
66
+ header_parts.append(symbol.signature)
67
+ if symbol.docstring:
68
+ header_parts.append(f'"""\n{symbol.docstring}\n"""')
69
+
70
+ header_content = "\n".join(header_parts).strip()
71
+ if header_content:
72
+ chunks.append(
73
+ CodeChunk(
74
+ symbol_name=symbol.name,
75
+ file_path=symbol.file_path,
76
+ chunk_type="signature_doc",
77
+ content=header_content,
78
+ start_line=symbol.start_line,
79
+ end_line=symbol.start_line + len(header_content.splitlines()),
80
+ chunk_index=0,
81
+ )
82
+ )
83
+
84
+ # If there is no body or body is very short, we are done
85
+ body_text = (symbol.body or "").strip()
86
+ if not body_text:
87
+ return chunks
88
+
89
+ # Estimate tokens (~4 chars per token for code)
90
+ estimated_tokens = len(body_text) // 4
91
+
92
+ if estimated_tokens <= self.max_tokens:
93
+ # Short body -> Single full chunk
94
+ chunks.append(
95
+ CodeChunk(
96
+ symbol_name=symbol.name,
97
+ file_path=symbol.file_path,
98
+ chunk_type="full",
99
+ content=body_text,
100
+ start_line=symbol.start_line,
101
+ end_line=symbol.end_line,
102
+ chunk_index=1 if chunks else 0,
103
+ )
104
+ )
105
+ else:
106
+ # Large body -> Break into overlapping line windows with context prefix
107
+ lines = body_text.splitlines()
108
+ # Approx 25 lines per chunk
109
+ lines_per_chunk = 25
110
+ stride = max(5, lines_per_chunk - self.chunk_overlap_lines)
111
+
112
+ chunk_idx = len(chunks)
113
+ for i in range(0, len(lines), stride):
114
+ chunk_lines = lines[i : i + lines_per_chunk]
115
+ if not chunk_lines:
116
+ break
117
+
118
+ chunk_content = f"{symbol.signature}\n" + "\n".join(chunk_lines)
119
+ actual_start = symbol.start_line + i
120
+ actual_end = min(symbol.end_line, symbol.start_line + i + len(chunk_lines))
121
+
122
+ chunks.append(
123
+ CodeChunk(
124
+ symbol_name=symbol.name,
125
+ file_path=symbol.file_path,
126
+ chunk_type="body_chunk",
127
+ content=chunk_content,
128
+ start_line=actual_start,
129
+ end_line=actual_end,
130
+ chunk_index=chunk_idx,
131
+ )
132
+ )
133
+ chunk_idx += 1
134
+
135
+ if i + lines_per_chunk >= len(lines):
136
+ break
137
+
138
+ return chunks