From 812333106d97159ff2fc1cd4c7366b30e90e8042 Mon Sep 17 00:00:00 2001 From: rajkumarsakthivel Date: Mon, 15 Jun 2026 21:05:54 +0100 Subject: [PATCH 1/4] chore: bump version to 0.4.23 --- pyproject.toml | 2 +- server.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fd3c1e1..ae8971f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "code-context-engine" -version = "0.4.22" +version = "0.4.23" description = "Save 94% on AI coding tokens. Index your codebase, agents search instead of reading files. Works with Claude Code, Cursor, Codex, Copilot, Gemini CLI. Local MCP server, free, open source." readme = {file = "README.md", content-type = "text/markdown"} license = "MIT" diff --git a/server.json b/server.json index 5525512..e157b69 100644 --- a/server.json +++ b/server.json @@ -7,13 +7,13 @@ "url": "https://github.com/elara-labs/code-context-engine", "source": "github" }, - "version": "0.4.22", + "version": "0.4.23", "packages": [ { "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "code-context-engine", - "version": "0.4.22", + "version": "0.4.23", "runtimeHint": "uvx", "transport": { "type": "stdio" From ae0dee7a86cd1759288026f78362d9e734f18e1d Mon Sep 17 00:00:00 2001 From: rajkumarsakthivel Date: Tue, 16 Jun 2026 19:51:33 +0100 Subject: [PATCH 2/4] fix: add encoding="utf-8" to all read_text/write_text calls On Windows, Path.read_text()/write_text() default to the system encoding (often cp1252), causing UnicodeDecodeError on files with non-ASCII bytes. This fixes cce init --agent copilot crashing on Windows (issue #105). --- src/context_engine/cli.py | 54 +++++++++---------- src/context_engine/dashboard/server.py | 6 +-- src/context_engine/editors.py | 22 ++++---- src/context_engine/indexer/git_hooks.py | 4 +- src/context_engine/integration/mcp_server.py | 6 +-- .../integration/session_capture.py | 6 +-- src/context_engine/memory/db.py | 2 +- src/context_engine/memory/hook_installer.py | 12 ++--- src/context_engine/memory/hook_server.py | 4 +- src/context_engine/memory/migrate.py | 2 +- src/context_engine/pricing.py | 4 +- src/context_engine/project_commands.py | 8 +-- src/context_engine/services.py | 8 +-- 13 files changed, 69 insertions(+), 69 deletions(-) diff --git a/src/context_engine/cli.py b/src/context_engine/cli.py index 103c829..a8466c3 100644 --- a/src/context_engine/cli.py +++ b/src/context_engine/cli.py @@ -84,7 +84,7 @@ def _check_for_update() -> str | None: # Read cache try: if _UPDATE_CACHE.exists(): - data = json.loads(_UPDATE_CACHE.read_text()) + data = json.loads(_UPDATE_CACHE.read_text(encoding="utf-8")) if time.time() - data.get("ts", 0) < _UPDATE_CHECK_TTL: latest = data.get("latest", "") if latest and _version_tuple(latest) > _version_tuple(current): @@ -110,7 +110,7 @@ def _check_for_update() -> str | None: # Cache result try: _CCE_HOME.mkdir(parents=True, exist_ok=True) - _UPDATE_CACHE.write_text(json.dumps({"ts": time.time(), "latest": latest or ""})) + _UPDATE_CACHE.write_text(json.dumps({"ts": time.time(), "latest": latest or ""}), encoding="utf-8") except Exception: pass @@ -158,7 +158,7 @@ def _configure_mcp(project_dir: Path) -> bool: if mcp_path.exists(): try: - data = json.loads(mcp_path.read_text()) + data = json.loads(mcp_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): data = {} else: @@ -356,7 +356,7 @@ def _check_memory_capture_reachable(config, project_dir: Path) -> None: ) return try: - port = int(port_file.read_text().strip()) + port = int(port_file.read_text(encoding="utf-8").strip()) except (OSError, ValueError): _warn(f"Memory capture port file unreadable at {port_file}") return @@ -388,7 +388,7 @@ def _ensure_session_hook(project_dir: Path) -> None: if settings_path.exists(): try: - data = json.loads(settings_path.read_text()) + data = json.loads(settings_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): data = {} else: @@ -408,7 +408,7 @@ def _ensure_session_hook(project_dir: Path) -> None: changed = True if changed: - settings_path.write_text(json.dumps(data, indent=2) + "\n") + settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") _ok("SessionStart hook installed for CCE status") @@ -461,7 +461,7 @@ def _show_welcome_banner(config) -> None: stats_path = storage_dir / "stats.json" if stats_path.exists(): try: - stats = _json.loads(stats_path.read_text()) + stats = _json.loads(stats_path.read_text(encoding="utf-8")) queries = stats.get("queries", 0) full_file = stats.get("full_file_tokens", 0) served = stats.get("served_tokens", 0) @@ -723,7 +723,7 @@ def _ensure_claude_md(project_dir: Path, output_level: str = "standard") -> None _ok("CLAUDE.md created with CCE instructions") return - existing = claude_md.read_text() + existing = claude_md.read_text(encoding="utf-8") # Already on the current version — nothing to do. if _CCE_CLAUDE_MD_VERSION_TAG in existing: @@ -877,7 +877,7 @@ def init(ctx: click.Context, agent: str) -> None: storage_dir = project_storage_dir(config, project_dir) storage_dir.mkdir(parents=True, exist_ok=True) meta_path = storage_dir / "meta.json" - meta_path.write_text(json.dumps({"project_dir": str(project_dir.resolve())})) + meta_path.write_text(json.dumps({"project_dir": str(project_dir.resolve())}), encoding="utf-8") # 3. Git hooks is_git_repo = (project_dir / ".git").exists() @@ -1018,7 +1018,7 @@ def status(ctx: click.Context, output_json: bool, oneline: bool) -> None: pass if stats_path.exists(): try: - stats = _json.loads(stats_path.read_text()) + stats = _json.loads(stats_path.read_text(encoding="utf-8")) q = stats.get("queries", 0) full = stats.get("full_file_tokens", 0) served = stats.get("served_tokens", 0) @@ -1094,7 +1094,7 @@ def status(ctx: click.Context, output_json: bool, oneline: bool) -> None: lines.append("") if stats_path.exists(): try: - stats = _json.loads(stats_path.read_text()) + stats = _json.loads(stats_path.read_text(encoding="utf-8")) raw = stats.get("raw_tokens", 0) full = stats.get("full_file_tokens", 0) served = stats.get("served_tokens", 0) @@ -1404,7 +1404,7 @@ def _load_stats(project_dir: Path) -> dict | None: if not stats_path.exists(): return None try: - return _json.loads(stats_path.read_text()) + return _json.loads(stats_path.read_text(encoding="utf-8")) except (KeyError, _json.JSONDecodeError): return None @@ -1904,10 +1904,10 @@ def clear(ctx: click.Context, yes: bool) -> None: manifest_path = storage_dir / "manifest.json" if manifest_path.exists(): - manifest_path.write_text(json.dumps({"__schema_version": 2, "files": {}})) + manifest_path.write_text(json.dumps({"__schema_version": 2, "files": {}}), encoding="utf-8") stats_path = storage_dir / "stats.json" - stats_path.write_text(json.dumps({"queries": 0, "raw_tokens": 0, "served_tokens": 0, "full_file_tokens": 0})) + stats_path.write_text(json.dumps({"queries": 0, "raw_tokens": 0, "served_tokens": 0, "full_file_tokens": 0}), encoding="utf-8") animate([ "", @@ -1941,7 +1941,7 @@ def prune(ctx: click.Context, dry_run: bool) -> None: kept.append((project_dir.name, "(no meta.json)")) continue try: - meta = json.loads(meta_path.read_text()) + meta = json.loads(meta_path.read_text(encoding="utf-8")) source_path = Path(meta.get("project_dir", "")) except (json.JSONDecodeError, OSError): kept.append((project_dir.name, "(unreadable meta.json)")) @@ -2052,13 +2052,13 @@ async def _search(): # Update stats stats_path = storage_dir / "stats.json" try: - stats = json.loads(stats_path.read_text()) if stats_path.exists() else {} + stats = json.loads(stats_path.read_text(encoding="utf-8")) if stats_path.exists() else {} except (json.JSONDecodeError, OSError): stats = {} stats["queries"] = stats.get("queries", 0) + 1 stats["full_file_tokens"] = stats.get("full_file_tokens", 0) + full_file_tokens stats["served_tokens"] = stats.get("served_tokens", 0) + served_tokens - stats_path.write_text(json.dumps(stats)) + stats_path.write_text(json.dumps(stats), encoding="utf-8") lines.append("") animate(lines) @@ -2092,7 +2092,7 @@ def uninstall(yes: bool) -> None: for hook_name in ["post-commit", "post-checkout", "post-merge"]: hook_file = hooks_dir / hook_name if hook_file.exists(): - content = hook_file.read_text() + content = hook_file.read_text(encoding="utf-8") if "cce" in content.lower() or "context-engine" in content.lower(): hook_file.unlink() removed_hooks += 1 @@ -2122,14 +2122,14 @@ def uninstall(yes: bool) -> None: # so the routing instructions don't get left behind. claude_md = project_dir / "CLAUDE.md" if claude_md.exists(): - content = claude_md.read_text() + content = claude_md.read_text(encoding="utf-8") block = _extract_existing_cce_block(content) legacy_begin = "" legacy_end = "" if block is not None: new_content = content.replace(block, "", 1).strip() if new_content: - claude_md.write_text(new_content + "\n") + claude_md.write_text(new_content + "\n", encoding="utf-8") else: claude_md.unlink() lines.append(f" {CROSS} {warn('Removed')} CCE block from CLAUDE.md") @@ -2142,7 +2142,7 @@ def uninstall(yes: bool) -> None: ) new_content = (content[:start] + content[end:]).strip() if new_content: - claude_md.write_text(new_content + "\n") + claude_md.write_text(new_content + "\n", encoding="utf-8") else: claude_md.unlink() lines.append(f" {CROSS} {warn('Removed')} CCE block from CLAUDE.md") @@ -2174,7 +2174,7 @@ def uninstall(yes: bool) -> None: if not settings_path.exists(): continue try: - data = json.loads(settings_path.read_text()) + data = json.loads(settings_path.read_text(encoding="utf-8")) hooks = data.get("hooks", {}) changed = False for event in list(hooks.keys()): @@ -2197,7 +2197,7 @@ def uninstall(yes: bool) -> None: if not hooks: del data["hooks"] if data: - settings_path.write_text(json.dumps(data, indent=2) + "\n") + settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") else: settings_path.unlink() # Remove empty .claude directory @@ -2211,7 +2211,7 @@ def uninstall(yes: bool) -> None: # Remove CCE entries from .gitignore (including comment lines) gitignore = project_dir / ".gitignore" if gitignore.exists(): - content = gitignore.read_text() + content = gitignore.read_text(encoding="utf-8") if ".cce" in content or "context-engine" in content.lower() or "cce" in content.lower() or ".claude/settings.local.json" in content: # These are the exact entries CCE adds (see project_commands._GITIGNORE_ENTRIES) cce_lines = {".cce/", ".claude/settings.local.json"} @@ -2223,7 +2223,7 @@ def uninstall(yes: bool) -> None: ] new_content = "\n".join(new_lines).strip() if new_content: - gitignore.write_text(new_content + "\n") + gitignore.write_text(new_content + "\n", encoding="utf-8") else: gitignore.unlink() lines.append(f" {CROSS} {warn('Removed')} CCE entries from .gitignore") @@ -3044,7 +3044,7 @@ def phase_fn(msg: str) -> None: _storage_dir = project_storage_dir(config, Path(project_dir)) stats_path = _storage_dir / "stats.json" try: - stats = json.loads(stats_path.read_text()) if stats_path.exists() else {} + stats = json.loads(stats_path.read_text(encoding="utf-8")) if stats_path.exists() else {} except (json.JSONDecodeError, OSError): stats = {} total_tokens = 0 @@ -3060,7 +3060,7 @@ def phase_fn(msg: str) -> None: pass stats["full_file_tokens"] = total_tokens stats_path.parent.mkdir(parents=True, exist_ok=True) - stats_path.write_text(json.dumps(stats)) + stats_path.write_text(json.dumps(stats), encoding="utf-8") async def _run_serve(config) -> None: diff --git a/src/context_engine/dashboard/server.py b/src/context_engine/dashboard/server.py index 59b5118..562dca1 100644 --- a/src/context_engine/dashboard/server.py +++ b/src/context_engine/dashboard/server.py @@ -87,7 +87,7 @@ async def csrf_and_auth(request: Request, call_next): def _read_json(path: Path) -> dict: if path.exists(): try: - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): pass return {} @@ -119,7 +119,7 @@ def _read_sessions(limit: int = 20) -> list[dict]: result = [] for f in files[:limit]: try: - result.append(json.loads(f.read_text())) + result.append(json.loads(f.read_text(encoding="utf-8"))) except (json.JSONDecodeError, OSError): pass return result @@ -420,7 +420,7 @@ async def delete_file(file_path: str) -> dict | JSONResponse: async def set_compression(req: CompressionRequest) -> dict: state = _read_state() state["output_level"] = req.level - (storage_base / "state.json").write_text(json.dumps(state)) + (storage_base / "state.json").write_text(json.dumps(state), encoding="utf-8") return {"level": req.level} @app.get("/api/export") diff --git a/src/context_engine/editors.py b/src/context_engine/editors.py index 195fe58..ce1a425 100644 --- a/src/context_engine/editors.py +++ b/src/context_engine/editors.py @@ -295,7 +295,7 @@ def configure_mcp(project_dir: Path, editor_key: str) -> bool | None: if config_path.exists(): try: - data = json.loads(config_path.read_text()) + data = json.loads(config_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): data = {} else: @@ -334,7 +334,7 @@ def _configure_opencode(config_path: Path, command: str, project_dir: str) -> bo if config_path.exists(): try: - content = config_path.read_text() + content = config_path.read_text(encoding="utf-8") # Strip JSONC comments for parsing data = json.loads(_strip_jsonc_comments(content)) except (json.JSONDecodeError, OSError): @@ -393,7 +393,7 @@ def _configure_toml( atomic_write_text(config_path, block) return True - original = config_path.read_text() + original = config_path.read_text(encoding="utf-8") except OSError: return None @@ -533,13 +533,13 @@ def remove_mcp(project_dir: Path, editor_key: str) -> str | None: servers_key = editor["servers_key"] try: - data = json.loads(config_path.read_text()) + data = json.loads(config_path.read_text(encoding="utf-8")) servers = data.get(servers_key, {}) if "context-engine" not in servers: return None del servers["context-engine"] if servers: - config_path.write_text(json.dumps(data, indent=2) + "\n") + config_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return f"Removed context-engine from {editor['config_path']}" else: config_path.unlink() @@ -557,7 +557,7 @@ def _remove_toml(config_path: Path, display_path: str, *, section: str) -> str | so it can never accidentally match a longer section that shares a prefix (e.g. removing `cce-api` won't touch `cce-api-staging`).""" try: - content = config_path.read_text() + content = config_path.read_text(encoding="utf-8") except OSError: return None @@ -593,13 +593,13 @@ def write_instruction_file( instructions = _build_instructions(output_level) if path.exists(): - content = path.read_text() + content = path.read_text(encoding="utf-8") if marker in content: return False # already has CCE block # Append - path.write_text(content.rstrip() + "\n\n" + instructions) + path.write_text(content.rstrip() + "\n\n" + instructions, encoding="utf-8") else: - path.write_text(instructions) + path.write_text(instructions, encoding="utf-8") return True @@ -612,7 +612,7 @@ def remove_instruction_file(project_dir: Path, file_key: str) -> str | None: if not path.exists(): return None - content = path.read_text() + content = path.read_text(encoding="utf-8") if marker not in content: return None @@ -628,7 +628,7 @@ def remove_instruction_file(project_dir: Path, file_key: str) -> str | None: new_content = (content[:start] + content[end:]).strip() if new_content: - path.write_text(new_content + "\n") + path.write_text(new_content + "\n", encoding="utf-8") return f"Removed CCE block from {info['name']}" else: path.unlink() diff --git a/src/context_engine/indexer/git_hooks.py b/src/context_engine/indexer/git_hooks.py index 0af4276..185c4a8 100644 --- a/src/context_engine/indexer/git_hooks.py +++ b/src/context_engine/indexer/git_hooks.py @@ -67,13 +67,13 @@ def install_hooks(project_dir: str) -> list[str]: def _install_single_hook(hook_path: Path) -> None: script = _hook_script() if hook_path.exists(): - existing = hook_path.read_text() + existing = hook_path.read_text(encoding="utf-8") if HOOK_MARKER in existing: return new_content = existing.rstrip() + "\n\n" + script else: new_content = "#!/bin/sh\n\n" + script - hook_path.write_text(new_content) + hook_path.write_text(new_content, encoding="utf-8") hook_path.chmod(hook_path.stat().st_mode | stat.S_IEXEC) diff --git a/src/context_engine/integration/mcp_server.py b/src/context_engine/integration/mcp_server.py index b4b9a69..a57aa5a 100644 --- a/src/context_engine/integration/mcp_server.py +++ b/src/context_engine/integration/mcp_server.py @@ -477,7 +477,7 @@ def _load_stats(self) -> dict: } if self._stats_path.exists(): try: - data = json.loads(self._stats_path.read_text()) + data = json.loads(self._stats_path.read_text(encoding="utf-8")) # Backfill new keys for stats files written by older versions. data.setdefault("queries", 0) data.setdefault("raw_tokens", 0) @@ -515,7 +515,7 @@ def _append_query_log(self) -> None: import datetime try: # Verify the write actually landed - on_disk = self._stats_path.read_text() if self._stats_path.exists() else "missing" + on_disk = self._stats_path.read_text(encoding="utf-8") if self._stats_path.exists() else "missing" log_path = self._storage_base / "query.log" q = self._stats["queries"] entry = ( @@ -580,7 +580,7 @@ def _append_error_log(self, msg: str) -> None: def _load_state(self) -> dict: if self._state_path.exists(): try: - return json.loads(self._state_path.read_text()) + return json.loads(self._state_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): pass return {} diff --git a/src/context_engine/integration/session_capture.py b/src/context_engine/integration/session_capture.py index 5a3b6b3..0c622ff 100644 --- a/src/context_engine/integration/session_capture.py +++ b/src/context_engine/integration/session_capture.py @@ -190,7 +190,7 @@ def _prune_locked( existing: list[dict] = [] if log_path.exists(): try: - existing = json.loads(log_path.read_text()) + existing = json.loads(log_path.read_text(encoding="utf-8")) if not isinstance(existing, list): existing = [] except (json.JSONDecodeError, OSError): @@ -201,7 +201,7 @@ def _prune_locked( if f == log_path: continue try: - data = json.loads(f.read_text()) + data = json.loads(f.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: log.warning("Skipping unreadable session file %s: %s", f, exc) continue @@ -244,7 +244,7 @@ def _load_consolidated_decisions(self) -> list[dict]: if not log_path.exists(): return [] try: - data = json.loads(log_path.read_text()) + data = json.loads(log_path.read_text(encoding="utf-8")) return data if isinstance(data, list) else [] except (json.JSONDecodeError, OSError): return [] diff --git a/src/context_engine/memory/db.py b/src/context_engine/memory/db.py index 8da332e..09e5858 100644 --- a/src/context_engine/memory/db.py +++ b/src/context_engine/memory/db.py @@ -837,7 +837,7 @@ def _harvest_and_delete(table: str, columns: list[str], cutoff: int) -> int: if archive and archived and archive_path is not None: try: - archive_path.write_text(_json.dumps(archived, indent=2, default=str)) + archive_path.write_text(_json.dumps(archived, indent=2, default=str), encoding="utf-8") log.info("memory: archived pruned rows to %s", archive_path) except OSError as exc: log.warning("memory: archive write failed (%s); rows still deleted", exc) diff --git a/src/context_engine/memory/hook_installer.py b/src/context_engine/memory/hook_installer.py index 813d6dd..e8bbe5b 100644 --- a/src/context_engine/memory/hook_installer.py +++ b/src/context_engine/memory/hook_installer.py @@ -190,10 +190,10 @@ def install_hook_script(target: Path = HOOK_PATH) -> bool: """ target.parent.mkdir(parents=True, exist_ok=True) body = _hook_script_body() - existing = target.read_text() if target.exists() else None + existing = target.read_text(encoding="utf-8") if target.exists() else None if existing == body: return False - target.write_text(body) + target.write_text(body, encoding="utf-8") if not _is_windows(): target.chmod( target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH @@ -215,7 +215,7 @@ def install_settings(project_dir: Path) -> dict: data: dict = {} if settings_path.exists(): try: - data = json.loads(settings_path.read_text() or "{}") + data = json.loads(settings_path.read_text(encoding="utf-8") or "{}") if not isinstance(data, dict): data = {} except json.JSONDecodeError: @@ -246,7 +246,7 @@ def install_settings(project_dir: Path) -> dict: }) added.append(hook_name) - settings_path.write_text(json.dumps(data, indent=2) + "\n") + settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return {"added": added, "skipped": skipped, "settings_path": str(settings_path)} @@ -256,7 +256,7 @@ def uninstall_settings(project_dir: Path) -> dict: if not settings_path.exists(): return {"removed": [], "settings_path": str(settings_path)} try: - data = json.loads(settings_path.read_text() or "{}") + data = json.loads(settings_path.read_text(encoding="utf-8") or "{}") except json.JSONDecodeError: return {"removed": [], "settings_path": str(settings_path)} if not isinstance(data, dict): @@ -279,7 +279,7 @@ def uninstall_settings(project_dir: Path) -> dict: if removed: if not hooks: data.pop("hooks", None) - settings_path.write_text(json.dumps(data, indent=2) + "\n") + settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") return {"removed": removed, "settings_path": str(settings_path)} diff --git a/src/context_engine/memory/hook_server.py b/src/context_engine/memory/hook_server.py index 3ca8c73..ede1833 100644 --- a/src/context_engine/memory/hook_server.py +++ b/src/context_engine/memory/hook_server.py @@ -85,12 +85,12 @@ async def _unlink_port_files(app): await site.start() port_file.parent.mkdir(parents=True, exist_ok=True) - port_file.write_text(str(port)) + port_file.write_text(str(port), encoding="utf-8") try: if default_rendezvous.resolve() != port_file.resolve(): default_rendezvous.parent.mkdir(parents=True, exist_ok=True) - default_rendezvous.write_text(str(port)) + default_rendezvous.write_text(str(port), encoding="utf-8") except OSError as exc: # Non-fatal — capture still works for users with default storage. log.warning("rendezvous port file write failed: %s", exc) diff --git a/src/context_engine/memory/migrate.py b/src/context_engine/memory/migrate.py index a36147b..a739e47 100644 --- a/src/context_engine/memory/migrate.py +++ b/src/context_engine/memory/migrate.py @@ -122,7 +122,7 @@ class _ImportCounts: def _import_one(conn: sqlite3.Connection, source: Path) -> _ImportCounts: """Import a single legacy JSON file. Returns counts of imported rows.""" counts = _ImportCounts() - data = json.loads(source.read_text()) + data = json.loads(source.read_text(encoding="utf-8")) # decisions_log.json is a top-level list of decision dicts, not a session. if source.name == _DECISIONS_LOG_NAME and isinstance(data, list): diff --git a/src/context_engine/pricing.py b/src/context_engine/pricing.py index 9606609..e57c2b5 100644 --- a/src/context_engine/pricing.py +++ b/src/context_engine/pricing.py @@ -138,7 +138,7 @@ def _load_cache() -> dict[str, ModelPricing] | None: try: if not _CACHE_PATH.exists(): return None - data = json.loads(_CACHE_PATH.read_text()) + data = json.loads(_CACHE_PATH.read_text(encoding="utf-8")) if time.time() - data.get("ts", 0) < _CACHE_TTL: raw = data.get("pricing") if not raw: @@ -159,7 +159,7 @@ def _load_cache() -> dict[str, ModelPricing] | None: def _save_cache(pricing: dict[str, ModelPricing]) -> None: try: _CACHE_PATH.parent.mkdir(parents=True, exist_ok=True) - _CACHE_PATH.write_text(json.dumps({"ts": time.time(), "pricing": pricing})) + _CACHE_PATH.write_text(json.dumps({"ts": time.time(), "pricing": pricing}), encoding="utf-8") except Exception: pass diff --git a/src/context_engine/project_commands.py b/src/context_engine/project_commands.py index 1ccb397..05099ed 100644 --- a/src/context_engine/project_commands.py +++ b/src/context_engine/project_commands.py @@ -50,7 +50,7 @@ def _load_yaml(path: Path) -> dict: if not path.exists(): return {} try: - data = yaml.safe_load(path.read_text()) or {} + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} except (yaml.YAMLError, OSError) as exc: log.warning("Failed to parse %s: %s", path, exc) return {} @@ -125,7 +125,7 @@ def save_commands(project_dir: str, commands: dict) -> None: """Save project commands to .cce/commands.yaml.""" path = _commands_path(project_dir) path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(yaml.dump(commands, default_flow_style=False, sort_keys=False)) + path.write_text(yaml.dump(commands, default_flow_style=False, sort_keys=False), encoding="utf-8") def add_command(project_dir: str, hook: str, command: str) -> None: @@ -244,7 +244,7 @@ def remove_preference(project_dir: str, key: str) -> bool: def ensure_gitignore(project_dir: str) -> None: """Add CCE-related entries to .gitignore if not already present.""" gitignore = Path(project_dir) / ".gitignore" - content = gitignore.read_text() if gitignore.exists() else "" + content = gitignore.read_text(encoding="utf-8") if gitignore.exists() else "" additions = [] for entry, comment in _GITIGNORE_ENTRIES: @@ -255,7 +255,7 @@ def ensure_gitignore(project_dir: str) -> None: return block = "\n\n# CCE (code-context-engine)\n" + "\n".join(additions) + "\n" - gitignore.write_text(content.rstrip() + block) + gitignore.write_text(content.rstrip() + block, encoding="utf-8") def format_for_prompt(commands: dict, label: str = "Project") -> str: diff --git a/src/context_engine/services.py b/src/context_engine/services.py index 51b8dd1..0a96e4c 100644 --- a/src/context_engine/services.py +++ b/src/context_engine/services.py @@ -41,13 +41,13 @@ def _pid_dir() -> Path: def _read_pid(name: str) -> int | None: p = _pid_dir() / f"{name}.pid" try: - return int(p.read_text().strip()) + return int(p.read_text(encoding="utf-8").strip()) except (FileNotFoundError, ValueError): return None def _write_pid(name: str, pid: int) -> None: - (_pid_dir() / f"{name}.pid").write_text(str(pid)) + (_pid_dir() / f"{name}.pid").write_text(str(pid), encoding="utf-8") def _remove_pid(name: str) -> None: @@ -165,7 +165,7 @@ def _is_remote_url(url: str) -> bool: def get_dashboard_status() -> dict: port_file = _pid_dir() / "dashboard.port" try: - port = int(port_file.read_text().strip()) + port = int(port_file.read_text(encoding="utf-8").strip()) except (FileNotFoundError, ValueError): port = None @@ -266,7 +266,7 @@ def start_dashboard(port: int = _DASHBOARD_DEFAULT_PORT) -> tuple[bool, str]: start_new_session=True, ) _write_pid("dashboard", proc.pid) - (_pid_dir() / "dashboard.port").write_text(str(port)) + (_pid_dir() / "dashboard.port").write_text(str(port), encoding="utf-8") return True, f"Dashboard started at http://localhost:{port} (PID {proc.pid})" except Exception as exc: return False, f"Failed to start dashboard: {exc}" From c6b479e4137bcaa3316ad41556e10bb0dc557b4c Mon Sep 17 00:00:00 2001 From: rajkumarsakthivel Date: Tue, 16 Jun 2026 20:06:31 +0100 Subject: [PATCH 3/4] fix: derive query count from memory.db, show last-query freshness hint - Query count now uses max(retrieval calls from memory.db, stats.json queries) so the display stays accurate even if stats.json is stale - Show "last query Xm/Xh/Xd ago" next to the project name so users can tell if savings data is current or unchanged since a prior session - Use stats.json mtime as fallback when memory.db has no savings_log data Fixes #107 --- src/context_engine/cli.py | 91 ++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/src/context_engine/cli.py b/src/context_engine/cli.py index a8466c3..bdad4ef 100644 --- a/src/context_engine/cli.py +++ b/src/context_engine/cli.py @@ -1408,11 +1408,12 @@ def _load_stats(project_dir: Path) -> dict | None: except (KeyError, _json.JSONDecodeError): return None - def _load_buckets(project_dir: Path) -> tuple[dict, dict]: + def _load_buckets(project_dir: Path) -> tuple[dict, dict, int | None]: """Open memory.db and pull per-bucket savings + the output_compression level histogram. Falls back to bucket data embedded in stats.json if memory.db is missing or empty. - Returns ({bucket: {baseline, served, calls}}, {level: count}). + Returns ({bucket: {baseline, served, calls}}, {level: count}, + last_savings_epoch_or_None). """ from context_engine.memory import db as _memory_db db_path = project_dir / "memory.db" @@ -1425,10 +1426,20 @@ def _load_buckets(project_dir: Path) -> tuple[dict, dict]: try: buckets = _memory_db.aggregate_savings(conn) levels = _memory_db.aggregate_output_compression_levels(conn) + # Last savings timestamp for freshness hint + last_ts = None + try: + row = conn.execute( + "SELECT MAX(ts) AS last_ts FROM savings_log" + ).fetchone() + if row and row["last_ts"]: + last_ts = int(row["last_ts"]) + except Exception: + pass # Only use if there's actual data total = sum(int(v.get("baseline", 0)) for v in buckets.values()) if total > 0: - return buckets, levels + return buckets, levels, last_ts finally: conn.close() except Exception: @@ -1446,9 +1457,15 @@ def _load_buckets(project_dir: Path) -> tuple[dict, dict]: } total = sum(v["baseline"] for v in buckets.values()) if total > 0: - return buckets, {} + # Use stats.json mtime as freshness hint + stats_mtime = None + try: + stats_mtime = int((project_dir / "stats.json").stat().st_mtime) + except OSError: + pass + return buckets, {}, stats_mtime - return empty, {} + return empty, {}, None from context_engine.cli_style import dim, bold from context_engine.pricing import resolve_pricing @@ -1538,8 +1555,16 @@ def _split_io(buckets: dict) -> tuple[int, int, int, int]: is_ += srv return ib, is_, ob, os_ - def _print_project(name: str, stats: dict, buckets: dict, levels: dict) -> None: - queries = stats.get("queries", 0) + def _print_project( + name: str, stats: dict, buckets: dict, levels: dict, + last_ts: int | None = None, + ) -> None: + # Prefer query count from memory.db (retrieval calls) when available. + # stats.json queries can go stale if the file isn't updated (e.g. + # atomic write fails, or the MCP server writes to a different path). + retrieval_calls = int(buckets.get("retrieval", {}).get("calls", 0)) + stats_queries = stats.get("queries", 0) + queries = max(retrieval_calls, stats_queries) # Prefer canonical bucket totals; fall back to legacy stats.json # fields if the project hasn't accumulated any bucket events yet. @@ -1569,8 +1594,28 @@ def _print_project(name: str, stats: dict, buckets: dict, levels: dict) -> None: q_label = "query" if queries == 1 else "queries" + # Freshness hint so users know when data was last updated + freshness = "" + if last_ts is not None: + import time as _time + age = int(_time.time()) - last_ts + if age < 60: + freshness = "just now" + elif age < 3600: + freshness = f"{age // 60}m ago" + elif age < 86400: + freshness = f"{age // 3600}h ago" + else: + freshness = f"{age // 86400}d ago" + click.echo() - click.echo(f" {bold(name)} {dim('·')} {value(str(queries))} {dim(q_label)}") + if freshness: + click.echo( + f" {bold(name)} {dim('·')} {value(str(queries))} {dim(q_label)}" + f" {dim('·')} {dim(f'last query {freshness}')}" + ) + else: + click.echo(f" {bold(name)} {dim('·')} {value(str(queries))} {dim(q_label)}") click.echo() # Show friendly message when no searches have happened yet. @@ -1749,9 +1794,11 @@ def _json_entry(name: str, stats: dict, buckets: dict, levels: dict) -> dict: if raw > 0 and served <= raw else 0 ) + retrieval_calls = int(buckets.get("retrieval", {}).get("calls", 0)) + queries = max(retrieval_calls, stats.get("queries", 0)) return { "project": name, - "queries": stats.get("queries", 0), + "queries": queries, "full_file_tokens": full_file, "raw_tokens": raw, "served_tokens": served, @@ -1785,16 +1832,16 @@ def _json_entry(name: str, stats: dict, buckets: dict, levels: dict) -> dict: # Each report carries its bucket totals and level histogram alongside # the legacy stats.json so downstream renderers/JSON emitters can # pick the canonical source. - reports: list[tuple[str, dict, dict, dict]] = [] + reports: list[tuple[str, dict, dict, dict, int | None]] = [] for pd in project_dirs: stats = _load_stats(pd) - buckets, levels = _load_buckets(pd) + buckets, levels, last_ts = _load_buckets(pd) bucket_baseline = sum(int(v.get("baseline", 0)) for v in buckets.values()) if stats is not None or bucket_baseline > 0: reports.append((pd.name, stats or { "queries": 0, "raw_tokens": 0, "served_tokens": 0, "full_file_tokens": 0, - }, buckets, levels)) + }, buckets, levels, last_ts)) if not reports: if as_json: @@ -1816,16 +1863,17 @@ def _json_entry(name: str, stats: dict, buckets: dict, levels: dict) -> dict: if as_json: if all_projects: click.echo(_json.dumps( - {"projects": [_json_entry(n, s, b, lv) for n, s, b, lv in reports]}, + {"projects": [_json_entry(n, s, b, lv) for n, s, b, lv, _ in reports]}, indent=2, )) else: - click.echo(_json.dumps(_json_entry(*reports[0]), indent=2)) + n, s, b, lv, _ = reports[0] + click.echo(_json.dumps(_json_entry(n, s, b, lv), indent=2)) return # Text output - for name, stats, buckets, levels in reports: - _print_project(name, stats, buckets, levels) + for name, stats, buckets, levels, last_ts in reports: + _print_project(name, stats, buckets, levels, last_ts) if len(reports) > 1: click.echo() click.echo(" " + "─" * 52) @@ -1844,14 +1892,17 @@ def _proj_served(s, b): if bt > 0: return bt return s.get("served_tokens", 0) - total_baseline = sum(_proj_baseline(s, b) for _, s, b, _ in reports) - total_served = sum(_proj_served(s, b) for _, s, b, _ in reports) - total_queries = sum(s.get("queries", 0) for _, s, _, _ in reports) + total_baseline = sum(_proj_baseline(s, b) for _, s, b, _, _ in reports) + total_served = sum(_proj_served(s, b) for _, s, b, _, _ in reports) + total_queries = sum( + max(int(b.get("retrieval", {}).get("calls", 0)), s.get("queries", 0)) + for _, s, b, _, _ in reports + ) total_saved = max(0, total_baseline - total_served) total_pct = int(total_saved / total_baseline * 100) if total_baseline > 0 else 0 # Aggregate input/output across all projects all_in_saved = all_out_saved = 0 - for _, stats, bkts, _ in reports: + for _, stats, bkts, _, _ in reports: ib, is_, ob, os_ = _split_io(bkts) all_in_saved += max(0, ib - is_) all_out_saved += max(0, ob - os_) From dd47c5a34239692a8927b4d6d3ec34467724f62b Mon Sep 17 00:00:00 2001 From: rajkumarsakthivel Date: Tue, 16 Jun 2026 21:08:55 +0100 Subject: [PATCH 4/4] fix: scope freshness timestamp to retrieval bucket --- src/context_engine/cli.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/context_engine/cli.py b/src/context_engine/cli.py index bdad4ef..24748b4 100644 --- a/src/context_engine/cli.py +++ b/src/context_engine/cli.py @@ -1430,7 +1430,8 @@ def _load_buckets(project_dir: Path) -> tuple[dict, dict, int | None]: last_ts = None try: row = conn.execute( - "SELECT MAX(ts) AS last_ts FROM savings_log" + "SELECT MAX(ts) AS last_ts FROM savings_log " + "WHERE bucket = 'retrieval'" ).fetchone() if row and row["last_ts"]: last_ts = int(row["last_ts"])