From 8957f98603902274099ca60c1909f36aa0551de4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=AA=E8=99=9A=E5=AD=90?= Date: Sat, 19 Sep 2026 18:16:35 +0800 Subject: [PATCH] Fix Windows compatibility: atomic rename and explicit UTF-8 I/O The scripts could not complete a run on Windows at all. Three independent issues, all stemming from POSIX assumptions: 1. Path.rename() overwrites an existing destination on POSIX, but raises FileExistsError (WinError 183) on Windows when the target exists. map.py used it on both cache and progress-file save paths, so indexing aborted before repo-map.db was ever written. Replaced with os.replace(), which atomically overwrites on both platforms. goals.py and context_saver.py already used os.replace(); map.py was the outlier. 2. write_text()/read_text() without an explicit encoding fall back to the locale codec -- GBK on zh-CN Windows -- which cannot encode characters such as U+1F4DD in the generated repo map. Added encoding="utf-8" to all 63 call sites across scripts/ and servers/. 3. sys.stdout/stderr also default to the locale codec, so the final print(repo_map) crashed with UnicodeEncodeError. map.py now reconfigures both streams to UTF-8. Verified on Windows 11 (zh-CN), Python 3.12 via uv: uv run --script scripts/map.py On a 600-file C++ project this now completes in ~17s and writes repo-map.db (7597 symbols), repo-map.md and repo-map-cache.json. This only unblocks indexing. The FTS5 table remains intentionally unpopulated, per docs/development.md. --- scripts/context_saver.py | 8 ++++---- scripts/discover-tools.py | 16 ++++++++-------- scripts/extract-context.py | 2 +- scripts/goals.py | 36 ++++++++++++++++++------------------ scripts/map.py | 25 +++++++++++++++++-------- scripts/process-guardian.py | 4 ++-- scripts/readme.py | 8 ++++---- scripts/refresh.py | 8 ++++---- scripts/scan.py | 14 +++++++------- scripts/servers.py | 4 ++-- scripts/setup-permissions.py | 4 ++-- scripts/update-narrative.py | 4 ++-- servers/repo-map-server.py | 6 +++--- 13 files changed, 74 insertions(+), 65 deletions(-) diff --git a/scripts/context_saver.py b/scripts/context_saver.py index ea8f57e..ec9bb42 100644 --- a/scripts/context_saver.py +++ b/scripts/context_saver.py @@ -48,7 +48,7 @@ def acquire_lock(lock_path: Path, timeout: float = 10.0): except FileExistsError: # Check if lock is stale try: - content = lock_path.read_text().strip() + content = lock_path.read_text(encoding="utf-8").strip() pid = int(content) # Check if process is alive try: @@ -188,7 +188,7 @@ def _merge_narrative( changes.append("Created new narrative.md") return "; ".join(changes) - text = path.read_text() + text = path.read_text(encoding="utf-8") sections = _read_sections(text) # Current Foci: replace entirely @@ -287,7 +287,7 @@ def _append_learnings(path: Path, learnings: list[dict]) -> int: existing = "" if path.exists(): - existing = path.read_text() + existing = path.read_text(encoding="utf-8") existing_lower = existing.lower() today = datetime.now().strftime("%Y-%m-%d") @@ -352,7 +352,7 @@ def trigger_git_update(claude_dir: Path): """Write trigger file for background git-based narrative update.""" claude_dir.mkdir(parents=True, exist_ok=True) trigger = claude_dir / ".update-narrative-trigger" - trigger.write_text(f"{os.getpid()}\n{time.time()}\n") + trigger.write_text(f"{os.getpid()}\n{time.time()}\n", encoding="utf-8") # --------------------------------------------------------------------------- diff --git a/scripts/discover-tools.py b/scripts/discover-tools.py index d651b94..b934f25 100644 --- a/scripts/discover-tools.py +++ b/scripts/discover-tools.py @@ -19,7 +19,7 @@ def extract_description_from_file(filepath: Path) -> str: """Extract a short description from a script file's first comment or docstring.""" try: - text = filepath.read_text(errors="replace") + text = filepath.read_text(errors="replace", encoding="utf-8") lines = text.splitlines() except (OSError, UnicodeDecodeError): return "" @@ -130,7 +130,7 @@ def discover_makefile_targets(root: Path) -> list[tuple[str, str]]: return results try: - text = makefile.read_text(errors="replace") + text = makefile.read_text(errors="replace", encoding="utf-8") except OSError: return results @@ -159,7 +159,7 @@ def discover_package_json_scripts(root: Path) -> list[tuple[str, str]]: return results try: - data = json.loads(pkg.read_text()) + data = json.loads(pkg.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return results @@ -189,7 +189,7 @@ def discover_pyproject_scripts(root: Path) -> list[tuple[str, str]]: return results try: - text = pyproject.read_text() + text = pyproject.read_text(encoding="utf-8") except OSError: return results @@ -226,7 +226,7 @@ def discover_justfile_targets(root: Path) -> list[tuple[str, str]]: return results try: - text = justfile.read_text(errors="replace") + text = justfile.read_text(errors="replace", encoding="utf-8") except OSError: return results @@ -260,7 +260,7 @@ def discover_taskfile_targets(root: Path) -> list[tuple[str, str]]: return results try: - text = taskfile.read_text(errors="replace") + text = taskfile.read_text(errors="replace", encoding="utf-8") except OSError: return results @@ -297,7 +297,7 @@ def discover_build_commands(root: Path) -> list[tuple[str, str]]: return results try: - data = json.loads(manifest.read_text()) + data = json.loads(manifest.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return results @@ -425,7 +425,7 @@ def main(): claude_dir = root / ".claude" claude_dir.mkdir(exist_ok=True) tools_md = claude_dir / "TOOLS.md" - tools_md.write_text(content) + tools_md.write_text(content, encoding="utf-8") print(f"Wrote {tools_md}", file=sys.stderr) # Also print to stdout for inspection diff --git a/scripts/extract-context.py b/scripts/extract-context.py index 09ab000..5d931ce 100644 --- a/scripts/extract-context.py +++ b/scripts/extract-context.py @@ -98,7 +98,7 @@ def main(): } if narrative_file.exists(): - content = narrative_file.read_text() + content = narrative_file.read_text(encoding="utf-8") # Truncate sections to prevent context bloat # Summary: concise overview (300 chars) # Foci: current work areas (400 chars) diff --git a/scripts/goals.py b/scripts/goals.py index e04ad08..cc44e34 100644 --- a/scripts/goals.py +++ b/scripts/goals.py @@ -145,7 +145,7 @@ def find_goal(goal_ref: str) -> Path: if not directory.exists(): continue for gf in directory.glob("*.md"): - content = gf.read_text() + content = gf.read_text(encoding="utf-8") m = re.search(r"^\*\*Slug\*\*:\s*(.+)$", content, re.MULTILINE) if m and m.group(1).strip() == goal_ref: slug_matches.append(gf) @@ -175,7 +175,7 @@ def find_goal_by_id(goal_id: str) -> Path | None: def parse_goal(path: Path) -> dict: """Parse a goal markdown file into a dict.""" - content = path.read_text() + content = path.read_text(encoding="utf-8") goal = {"path": str(path), "raw": content} # Parse header fields @@ -372,7 +372,7 @@ def migrate_goal(path: Path) -> dict: if not needs_migration(goal): return goal - content = path.read_text() + content = path.read_text(encoding="utf-8") # Add Slug field after ID if missing if not goal.get("slug"): @@ -545,12 +545,12 @@ def goal_list(show_all: bool = False, project_path: str | None = None) -> str: current_path = claude_dir / ".current-goal" current_id = "" if current_path.exists(): - current_id, _ = parse_current_goal(current_path.read_text()) + current_id, _ = parse_current_goal(current_path.read_text(encoding="utf-8")) if not index_path.exists(): return "No goals linked to this project. Use 'goal create' or 'goal sync'." - index = json.loads(index_path.read_text()) + index = json.loads(index_path.read_text(encoding="utf-8")) goals = index.get("goals", []) if not goals: return "No goals linked to this project." @@ -576,10 +576,10 @@ def goal_show(goal_ref: str | None = None, project_path: str | None = None) -> s current_path = claude_dir / ".current-goal" if not current_path.exists(): raise ValueError("No current goal set. Use 'goal switch ' or 'goal create'.") - goal_ref, _ = parse_current_goal(current_path.read_text()) + goal_ref, _ = parse_current_goal(current_path.read_text(encoding="utf-8")) path = find_goal(goal_ref) - return path.read_text() + return path.read_text(encoding="utf-8") def goal_switch(goal_ref: str, project_path: str | None = None) -> str: @@ -619,7 +619,7 @@ def goal_focus(goal_ref: str | None, step_id: str, current_path = claude_dir / ".current-goal" if not current_path.exists(): raise ValueError("No current goal set.") - goal_ref, _ = parse_current_goal(current_path.read_text()) + goal_ref, _ = parse_current_goal(current_path.read_text(encoding="utf-8")) path = find_goal(goal_ref) goal = parse_goal(path) @@ -645,7 +645,7 @@ def goal_focus(goal_ref: str | None, step_id: str, target_step["current"] = True # Rebuild file - content = path.read_text() + content = path.read_text(encoding="utf-8") content = _rebuild_plan_section(content, steps) content = _update_timestamp(content) atomic_write(path, content) @@ -704,7 +704,7 @@ def goal_update_step(goal_ref: str, step_ref: str | int, steps[idx]["current"] = True # Rebuild the file - content = path.read_text() + content = path.read_text(encoding="utf-8") content = _rebuild_plan_section(content, steps) content = _update_timestamp(content) atomic_write(path, content) @@ -732,7 +732,7 @@ def goal_add_learning(goal_ref: str, text: str) -> str: now = datetime.now().strftime("%Y-%m-%d") entry = f"\n### {now}\n{text}\n" - content = path.read_text() + content = path.read_text(encoding="utf-8") # Insert before "## Recent Activity" m = re.search(r"^## Recent Activity", content, re.MULTILINE) @@ -757,7 +757,7 @@ def goal_add_commit(goal_ref: str, commit_hash: str, message: str, project_name = Path.cwd().name entry = f"- `{commit_hash}` ({project_name}) {now}: {message}" - content = path.read_text() + content = path.read_text(encoding="utf-8") m = re.search(r"^## Recent Activity\n\n?((?:- .+\n?)*)", content, re.MULTILINE) if m: @@ -818,7 +818,7 @@ def goal_add_step(goal_ref: str, description: str, if not any(s.get("current") for s in steps): new_step["current"] = True - content = path.read_text() + content = path.read_text(encoding="utf-8") content = _rebuild_plan_section(content, steps) content = _update_timestamp(content) atomic_write(path, content) @@ -833,7 +833,7 @@ def goal_link_project(goal_ref: str, link_path: str, path = find_goal(goal_ref) project_path = str(Path(link_path).resolve()) - content = path.read_text() + content = path.read_text(encoding="utf-8") # Check for exact project path match (not substring) goal = parse_goal(path) @@ -864,7 +864,7 @@ def goal_archive(goal_ref: str, project_path: str | None = None) -> str: path = find_goal(goal_ref) ensure_dirs() - content = path.read_text() + content = path.read_text(encoding="utf-8") content = re.sub(r"(\*\*Status\*\*:\s*).+", r"\1archived", content) content = _update_timestamp(content) @@ -876,7 +876,7 @@ def goal_archive(goal_ref: str, project_path: str | None = None) -> str: claude_dir = get_claude_dir(project_path) current_path = claude_dir / ".current-goal" if current_path.exists(): - current_id, _ = parse_current_goal(current_path.read_text()) + current_id, _ = parse_current_goal(current_path.read_text(encoding="utf-8")) if current_id == path.stem: current_path.unlink() @@ -888,7 +888,7 @@ def goal_sync(project_path: str | None = None) -> str: """Rebuild active-goals.json from goal files. Returns confirmation.""" update_index(project_path=project_path) claude_dir = get_claude_dir(project_path) - index = json.loads((claude_dir / "active-goals.json").read_text()) + index = json.loads((claude_dir / "active-goals.json").read_text(encoding="utf-8")) count = len(index.get("goals", [])) return f"Synced: {count} goal(s) linked to this project." @@ -907,7 +907,7 @@ def goal_context(project_path: str | None = None) -> dict: if not current_path.exists(): return {} - goal_uuid, focused_step_id = parse_current_goal(current_path.read_text()) + goal_uuid, focused_step_id = parse_current_goal(current_path.read_text(encoding="utf-8")) try: path = find_goal(goal_uuid) diff --git a/scripts/map.py b/scripts/map.py index b8f956c..9e2d3f8 100644 --- a/scripts/map.py +++ b/scripts/map.py @@ -35,6 +35,15 @@ import tree_sitter_rust as tsrust from tree_sitter import Language, Parser, Node +# Windows fix: the console codec defaults to the system locale (e.g. GBK on +# zh-CN), which cannot encode characters such as U+1F4DD used in the generated +# map. Reconfigure stdio to UTF-8 so `print(repo_map)` does not crash. +try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + # Cache format version - bump when Symbol structure or file selection changes CACHE_VERSION = 6 # v6: Added .mm (Objective-C++) and .metal file support @@ -129,7 +138,7 @@ def _load(self) -> None: if not self.cache_path.exists(): return try: - data = json.loads(self.cache_path.read_text()) + data = json.loads(self.cache_path.read_text(encoding="utf-8")) if data.get("version") != CACHE_VERSION: return # Invalidate cache on version mismatch self.found_file_count = data.get("found_file_count") @@ -148,8 +157,8 @@ def save(self) -> None: self.cache_path.parent.mkdir(parents=True, exist_ok=True) # Write to temp file then rename for atomicity tmp_path = self.cache_path.with_suffix(".tmp") - tmp_path.write_text(json.dumps(data)) - tmp_path.rename(self.cache_path) + tmp_path.write_text(json.dumps(data), encoding="utf-8") + os.replace(tmp_path, self.cache_path) self._dirty_count = 0 def save_if_needed(self) -> None: @@ -1114,7 +1123,7 @@ def update_progress(status: str, completed: int = 0, total: int = 0, symbols: in "timestamp": time.time(), } try: - progress_path.write_text(json.dumps(progress_data)) + progress_path.write_text(json.dumps(progress_data), encoding="utf-8") except IOError: pass @@ -1183,8 +1192,8 @@ def update_progress(status: str, completed: int = 0, total: int = 0, symbols: in # Write to .in-progress first, then rename atomically in_progress_path = claude_dir / "repo-map.md.in-progress" final_path = claude_dir / "repo-map.md" - in_progress_path.write_text(repo_map) - in_progress_path.rename(final_path) + in_progress_path.write_text(repo_map, encoding="utf-8") + os.replace(in_progress_path, final_path) # Write final progress status progress_path = claude_dir / "repo-map-progress.json" @@ -1197,13 +1206,13 @@ def update_progress(status: str, completed: int = 0, total: int = 0, symbols: in "symbols_found": len(all_symbols), "timestamp": time.time(), } - progress_path.write_text(json.dumps(progress_data)) + progress_path.write_text(json.dumps(progress_data), encoding="utf-8") # Clean up PID file if we're the process that wrote it pid_file = claude_dir / ".indexing-pid" if pid_file.exists(): try: - stored_pid = int(pid_file.read_text().strip()) + stored_pid = int(pid_file.read_text(encoding="utf-8").strip()) if stored_pid == os.getpid(): pid_file.unlink() except (ValueError, OSError): diff --git a/scripts/process-guardian.py b/scripts/process-guardian.py index 61c5a64..473bb2f 100644 --- a/scripts/process-guardian.py +++ b/scripts/process-guardian.py @@ -61,7 +61,7 @@ def read_pids(pidfile: Path) -> list[tuple[int, str]]: if not pidfile.exists(): return [] pids = [] - for line in pidfile.read_text().splitlines(): + for line in pidfile.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#"): continue @@ -127,7 +127,7 @@ def handle_signal(signum, frame): if len(alive) < len(pids) and pidfile.exists(): pidfile.write_text( "\n".join(f"{pid}:{label}" for pid, label in alive) + "\n" - ) + , encoding="utf-8") # If no children left to watch, exit if not alive: diff --git a/scripts/readme.py b/scripts/readme.py index ac141d2..5444020 100644 --- a/scripts/readme.py +++ b/scripts/readme.py @@ -158,11 +158,11 @@ def main(): print("Run story.py first.", file=sys.stderr) sys.exit(1) - narrative = narrative_file.read_text() + narrative = narrative_file.read_text(encoding="utf-8") existing_readme = None if args.update and readme_file.exists(): - existing_readme = readme_file.read_text() + existing_readme = readme_file.read_text(encoding="utf-8") print("Updating existing README...", file=sys.stderr) else: print("Generating new README...", file=sys.stderr) @@ -175,10 +175,10 @@ def main(): else: if readme_file.exists(): backup = project_root / "README.md.bak" - backup.write_text(readme_file.read_text()) + backup.write_text(readme_file.read_text(), encoding="utf-8") print(f"Backup saved to {backup}", file=sys.stderr) - readme_file.write_text(readme) + readme_file.write_text(readme, encoding="utf-8") print(f"README saved to {readme_file}", file=sys.stderr) print(readme) diff --git a/scripts/refresh.py b/scripts/refresh.py index ab1f95c..b74b2aa 100644 --- a/scripts/refresh.py +++ b/scripts/refresh.py @@ -102,11 +102,11 @@ def main(): print("Run story.py first to create initial narrative.", file=sys.stderr) sys.exit(1) - current_narrative = narrative_file.read_text() + current_narrative = narrative_file.read_text(encoding="utf-8") # Get session summary if args.file: - session_summary = Path(args.file).read_text() + session_summary = Path(args.file).read_text(encoding="utf-8") elif args.summary: session_summary = args.summary elif not sys.stdin.isatty(): @@ -131,11 +131,11 @@ def main(): else: # Backup current narrative backup_file = project_root / ".claude" / "narrative.md.bak" - backup_file.write_text(current_narrative) + backup_file.write_text(current_narrative, encoding="utf-8") print(f"Backup saved to {backup_file}", file=sys.stderr) # Save updated narrative - narrative_file.write_text(updated_narrative) + narrative_file.write_text(updated_narrative, encoding="utf-8") print(f"Narrative updated at {narrative_file}", file=sys.stderr) print(updated_narrative) diff --git a/scripts/scan.py b/scripts/scan.py index 29e9087..eec10e0 100644 --- a/scripts/scan.py +++ b/scripts/scan.py @@ -51,7 +51,7 @@ def detect_build_systems(root: Path) -> list[BuildSystem]: # Python ecosystems if (root / "pyproject.toml").exists(): - content = (root / "pyproject.toml").read_text() + content = (root / "pyproject.toml").read_text(encoding="utf-8") if "[project]" in content or "[tool.poetry]" in content: manager = "uv" @@ -109,7 +109,7 @@ def detect_build_systems(root: Path) -> list[BuildSystem]: # Standalone Rust if (root / "Cargo.toml").exists(): - cargo_content = (root / "Cargo.toml").read_text() + cargo_content = (root / "Cargo.toml").read_text(encoding="utf-8") is_workspace = "[workspace]" in cargo_content has_lib_or_bin = "[lib]" in cargo_content or "[[bin]]" in cargo_content @@ -133,7 +133,7 @@ def detect_build_systems(root: Path) -> list[BuildSystem]: # Node.js / TypeScript if (root / "package.json").exists(): - pkg_content = (root / "package.json").read_text() + pkg_content = (root / "package.json").read_text(encoding="utf-8") try: pkg = json.loads(pkg_content) has_typescript = (root / "tsconfig.json").exists() @@ -202,7 +202,7 @@ def detect_build_systems(root: Path) -> list[BuildSystem]: # Makefile if (root / "Makefile").exists(): if not any(s.language == "c/c++" for s in systems): - makefile_content = (root / "Makefile").read_text() + makefile_content = (root / "Makefile").read_text(encoding="utf-8") lang = "c/c++" if "rustc" in makefile_content or "cargo" in makefile_content: lang = "rust" @@ -276,7 +276,7 @@ def get_python_commands(root: Path, manager: str) -> dict: pyproject = root / "pyproject.toml" if pyproject.exists(): - content = pyproject.read_text() + content = pyproject.read_text(encoding="utf-8") if "pytest" in content: commands["test"] = f"{prefix} pytest" if prefix else "pytest" @@ -322,14 +322,14 @@ def find_entry_points(root: Path, build_systems: list[BuildSystem]) -> list[dict seen_paths.add(pattern) pyproject = root / "pyproject.toml" - if pyproject.exists() and "[project.scripts]" in pyproject.read_text(): + if pyproject.exists() and "[project.scripts]" in pyproject.read_text(encoding="utf-8"): entry_points.append({"path": "pyproject.toml [project.scripts]", "type": "cli-entrypoint", "system": system.name}) elif system.language in ("typescript", "javascript"): pkg_path = root / "package.json" if pkg_path.exists(): try: - pkg = json.loads(pkg_path.read_text()) + pkg = json.loads(pkg_path.read_text(encoding="utf-8")) for key in ["main", "module", "bin"]: if key in pkg: val = pkg[key] diff --git a/scripts/servers.py b/scripts/servers.py index c071ac2..925fe94 100644 --- a/scripts/servers.py +++ b/scripts/servers.py @@ -221,7 +221,7 @@ def get_cache_info(project_root: str) -> dict | None: return None try: - data = json.loads(cache_path.read_text()) + data = json.loads(cache_path.read_text(encoding="utf-8")) return { "found_file_count": data.get("found_file_count"), "cached_file_count": len(data.get("files", {})), @@ -253,7 +253,7 @@ def get_recent_logs(project_root: str, lines: int = 20) -> list[str]: return [] try: - all_lines = log_path.read_text().splitlines() + all_lines = log_path.read_text(encoding="utf-8").splitlines() return all_lines[-lines:] except OSError: return [] diff --git a/scripts/setup-permissions.py b/scripts/setup-permissions.py index 0d1026b..7bf9fdb 100644 --- a/scripts/setup-permissions.py +++ b/scripts/setup-permissions.py @@ -26,7 +26,7 @@ def setup_permissions() -> bool: if settings_path.exists(): try: - settings = json.loads(settings_path.read_text()) + settings = json.loads(settings_path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as e: print(f"Error reading {settings_path}: {e}", file=sys.stderr) return False @@ -49,7 +49,7 @@ def setup_permissions() -> bool: # Write back with consistent formatting settings_path.parent.mkdir(parents=True, exist_ok=True) - settings_path.write_text(json.dumps(settings, indent=2) + "\n") + settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") for p in added: print(f"Added '{p}' to {settings_path}", file=sys.stderr) return True diff --git a/scripts/update-narrative.py b/scripts/update-narrative.py index 87b0fba..f22ac47 100644 --- a/scripts/update-narrative.py +++ b/scripts/update-narrative.py @@ -208,7 +208,7 @@ def main(): sys.exit(1) # Read current narrative - current_narrative = narrative_file.read_text() + current_narrative = narrative_file.read_text(encoding="utf-8") # Update narrative updated_narrative = update_narrative(current_narrative, session_summary) @@ -221,7 +221,7 @@ def main(): backup = backup_narrative(narrative_file) print(f"Backup saved to {backup}", file=sys.stderr) - narrative_file.write_text(updated_narrative) + narrative_file.write_text(updated_narrative, encoding="utf-8") print(f"Narrative updated: {narrative_file}", file=sys.stderr) # Print summary of changes diff --git a/servers/repo-map-server.py b/servers/repo-map-server.py index 2e325d9..1cd0a49 100644 --- a/servers/repo-map-server.py +++ b/servers/repo-map-server.py @@ -285,7 +285,7 @@ def is_stale(full_check: bool = False) -> tuple[bool, str]: # Check cache version try: - cache_data = json.loads(cache_path.read_text()) + cache_data = json.loads(cache_path.read_text(encoding="utf-8")) if cache_data.get("version") != indexer.CACHE_VERSION: return True, f"cache version mismatch" except (json.JSONDecodeError, IOError): @@ -337,7 +337,7 @@ def _kill_external_indexer(): if not pid_file.exists(): return try: - ext_pid = int(pid_file.read_text().strip()) + ext_pid = int(pid_file.read_text(encoding="utf-8").strip()) os.kill(ext_pid, signal.SIGTERM) logger.info(f"Killed external indexing process (PID: {ext_pid})") # Brief wait for clean shutdown @@ -1119,7 +1119,7 @@ def get_indexing_progress() -> dict | None: return None try: - data = json.loads(progress_path.read_text()) + data = json.loads(progress_path.read_text(encoding="utf-8")) # Calculate percentage if we have the data status = data.get("status", "unknown")