gemcode 0.3.75__py3-none-any.whl → 0.3.77__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 (79) hide show
  1. gemcode/agent.py +51 -18
  2. gemcode/autocompact.py +2 -2
  3. gemcode/autotune.py +76 -0
  4. gemcode/callbacks.py +6 -6
  5. gemcode/capability_routing.py +2 -2
  6. gemcode/checkpoints.py +144 -0
  7. gemcode/cli.py +63 -28
  8. gemcode/config.py +21 -4
  9. gemcode/context_budget.py +2 -2
  10. gemcode/context_warning.py +6 -6
  11. gemcode/credentials.py +1 -1
  12. gemcode/curated_memory.py +110 -0
  13. gemcode/evals/harness.py +126 -0
  14. gemcode/hooks.py +1 -1
  15. gemcode/ide_protocol.py +14 -1
  16. gemcode/ide_stdio.py +52 -10
  17. gemcode/invoke.py +1 -1
  18. gemcode/{kairos_daemon.py → kaira_daemon.py} +19 -19
  19. gemcode/learning.py +122 -0
  20. gemcode/limits.py +1 -1
  21. gemcode/mcp_loader.py +1 -1
  22. gemcode/modality_tools.py +1 -1
  23. gemcode/model_routing.py +1 -1
  24. gemcode/output_styles.py +78 -0
  25. gemcode/paths.py +60 -0
  26. gemcode/permissions.py +5 -2
  27. gemcode/plugins/terminal_hooks_plugin.py +13 -1
  28. gemcode/plugins/tool_recovery_plugin.py +2 -2
  29. gemcode/prompt_suggestions.py +2 -2
  30. gemcode/query/__init__.py +1 -1
  31. gemcode/query/config.py +1 -1
  32. gemcode/query/deps.py +1 -1
  33. gemcode/query/engine.py +1 -1
  34. gemcode/query/stop_hooks.py +1 -1
  35. gemcode/query/token_budget.py +2 -2
  36. gemcode/query/transitions.py +1 -1
  37. gemcode/repl_commands.py +13 -3
  38. gemcode/repl_slash.py +513 -9
  39. gemcode/rules.py +115 -0
  40. gemcode/session_runtime.py +10 -1
  41. gemcode/skills.py +299 -0
  42. gemcode/slash_commands.py +1 -1
  43. gemcode/thinking.py +12 -17
  44. gemcode/tool_prompt_manifest.py +1 -1
  45. gemcode/tool_registry.py +2 -2
  46. gemcode/tool_result_store.py +1 -1
  47. gemcode/tools/__init__.py +26 -0
  48. gemcode/tools/bash.py +1 -1
  49. gemcode/tools/curated_memory.py +34 -0
  50. gemcode/tools/edit.py +65 -2
  51. gemcode/tools/filesystem.py +65 -13
  52. gemcode/tools/notebook.py +2 -2
  53. gemcode/tools/notes.py +2 -3
  54. gemcode/tools/repo_map.py +11 -0
  55. gemcode/tools/search.py +63 -19
  56. gemcode/tools/shell.py +2 -1
  57. gemcode/tools/skills.py +61 -0
  58. gemcode/tools/subtask.py +1 -1
  59. gemcode/tools/tasks.py +2 -2
  60. gemcode/tools/think.py +1 -2
  61. gemcode/tools/todo.py +1 -1
  62. gemcode/tools/web.py +1 -1
  63. gemcode/tools/web_search.py +1 -1
  64. gemcode/tools_inspector.py +1 -1
  65. gemcode/tui/input_handler.py +8 -3
  66. gemcode/tui/scrollback.py +3 -3
  67. gemcode/tui/spinner.py +8 -8
  68. gemcode/tui/welcome_rich.py +1 -1
  69. gemcode/web/{claude_sse_adapter.py → sse_adapter.py} +26 -61
  70. gemcode/web/terminal_repl.py +3 -3
  71. gemcode/web/web_sse_compat.py +24 -0
  72. gemcode-0.3.77.dist-info/METADATA +689 -0
  73. gemcode-0.3.77.dist-info/RECORD +108 -0
  74. gemcode-0.3.75.dist-info/METADATA +0 -510
  75. gemcode-0.3.75.dist-info/RECORD +0 -97
  76. {gemcode-0.3.75.dist-info → gemcode-0.3.77.dist-info}/WHEEL +0 -0
  77. {gemcode-0.3.75.dist-info → gemcode-0.3.77.dist-info}/entry_points.txt +0 -0
  78. {gemcode-0.3.75.dist-info → gemcode-0.3.77.dist-info}/licenses/LICENSE +0 -0
  79. {gemcode-0.3.75.dist-info → gemcode-0.3.77.dist-info}/top_level.txt +0 -0
gemcode/agent.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Root LlmAgent definition (Claude Code: agent config + tool list, analogous to tools.ts + prompts).
2
+ Root LlmAgent definition (agent config + tool list, analogous to a tools registry + prompts).
3
3
 
4
4
  See `session_runtime.py` for Runner/session wiring (outer layer).
5
5
  See `tool_registry.py` for tool categories (read vs mutating vs shell).
@@ -27,6 +27,9 @@ from gemcode.limits import make_before_model_limits_callback, make_before_model_
27
27
  from gemcode.thinking import build_thinking_config
28
28
  from gemcode.tools import build_function_tools
29
29
  from gemcode.tool_prompt_manifest import build_tool_manifest
30
+ from gemcode.skills import build_skill_manifest_text
31
+ from gemcode.output_styles import build_output_style_section
32
+ from gemcode.rules import build_rules_section
30
33
 
31
34
 
32
35
  def build_global_instruction() -> str:
@@ -61,7 +64,7 @@ def _chain_before_model_callbacks(*callbacks):
61
64
 
62
65
  def _load_gemini_md(project_root: Path) -> str:
63
66
  """
64
- Load GEMINI.md / .gemcode/NOTES.md from a Claude Code–style hierarchy.
67
+ Load GEMINI.md / .gemcode/NOTES.md from a interactive CLI–style hierarchy.
65
68
 
66
69
  Priority (later entries override earlier ones, all are concatenated):
67
70
  1. ~/.gemcode/GEMINI.md — user-global instructions (all projects)
@@ -86,7 +89,7 @@ def _load_gemini_md(project_root: Path) -> str:
86
89
  return ""
87
90
  try:
88
91
  raw = p.read_text(encoding="utf-8", errors="replace")[:_FILE_CAP]
89
- # Strip HTML comments (like Claude Code does — saves tokens)
92
+ # Strip HTML comments (saves tokens)
90
93
  return _COMMENT_RE.sub("", raw).strip()
91
94
  except OSError:
92
95
  return ""
@@ -137,7 +140,7 @@ def _get_git_context(root) -> str:
137
140
  """
138
141
  Run a quick git snapshot at session start — branch, recent commits, diff-stat.
139
142
  Returns a formatted string or empty string if not a git repo.
140
- Mirrors OpenClaude's getGitStatus() pattern.
143
+ Mirrors Reference UI getGitStatus() pattern.
141
144
  """
142
145
  import subprocess
143
146
  import shutil
@@ -229,18 +232,18 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
229
232
  if max_session_tokens:
230
233
  budget_line += f" · max_session_tokens={max_session_tokens:,}"
231
234
 
232
- # ── Kairos ────────────────────────────────────────────────────────────────
233
- # The user can run `gemcode kairos -C <project>` in a separate terminal to
235
+ # ── Kaira ────────────────────────────────────────────────────────────────
236
+ # The user can run `gemcode kaira -C <project>` in a separate terminal to
234
237
  # launch a long-lived scheduler. Jobs submitted to it run concurrently with
235
238
  # the current session. This is useful for background / parallel heavy work.
236
- kairos_section = (
237
- "- **Kairos background scheduler** — `gemcode kairos -C <project>` launches a "
239
+ kaira_section = (
240
+ "- **Kaira background scheduler** — `gemcode kaira -C <project>` launches a "
238
241
  "long-lived daemon that reads prompts from stdin and runs each as an isolated job "
239
- "(up to N concurrently). Each job gets `kairos_sleep_ms(ms)` and "
240
- "`kairos_enqueue_prompt(prompt, priority, session_id)` tools so the model can "
242
+ "(up to N concurrently). Each job gets `kaira_sleep_ms(ms)` and "
243
+ "`kaira_enqueue_prompt(prompt, priority, session_id)` tools so the model can "
241
244
  "schedule follow-up work itself. Useful for: bulk file processing, repeated "
242
245
  "polling loops, parallelising large independent tasks. "
243
- "Tell the user to open a second terminal and run `gemcode kairos` if a task "
246
+ "Tell the user to open a second terminal and run `gemcode kaira` if a task "
244
247
  "would benefit from background parallelism."
245
248
  )
246
249
 
@@ -248,6 +251,19 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
248
251
  git_ctx = _get_git_context(root)
249
252
  git_section = f"\n\n## Git context (snapshot at session start)\n{git_ctx}" if git_ctx else ""
250
253
 
254
+ # ── Curated memory (safe-to-inject) ───────────────────────────────────────
255
+ curated_section = ""
256
+ try:
257
+ snap = getattr(cfg, "_curated_memory_snapshot", None)
258
+ if isinstance(snap, dict) and (snap.get("text") or "").strip():
259
+ curated_section = (
260
+ "\n\n## Curated memory (safe, persistent)\n"
261
+ "This is small, curated memory that should be treated as durable project/user facts.\n"
262
+ f"{snap.get('text')}\n"
263
+ )
264
+ except Exception:
265
+ curated_section = ""
266
+
251
267
  return f"""## Runtime facts (authoritative for this session)
252
268
  - **Today's date:** {today}
253
269
  - **Project root** — every filesystem tool path is relative to: `{root}`
@@ -258,10 +274,10 @@ def _build_runtime_facts(cfg: GemCodeConfig) -> str:
258
274
  - **Capability routing** (`capability_mode={getattr(cfg, 'capability_mode', 'auto')}`): in `auto` mode, GemCode automatically enables deep_research when it detects research-intent keywords in your prompt each turn. You can also type `/research on`, `/embeddings on`, `/memory on`, `/computer on` at the prompt.
259
275
  - **Your tool palette can grow mid-session:** if the user enables a capability via a slash command, the runner rebuilds and you get new tools on the next turn.
260
276
  - **Memory system:** when `memory ON`, ADK automatically searches `.gemcode/memories.jsonl` and injects relevant past context before each turn. Facts the user tells you in one session can appear in future sessions. You do not need to manage memory explicitly — it is loaded automatically.
261
- {kairos_section}
277
+ {kaira_section}
262
278
  - **UI banner** phrases like "GemCode Pro" are terminal marketing, not a separate API tier.
263
279
  - **Env toggles** (`GEMCODE_ENABLE_COMPUTER_USE`, `GEMCODE_MODEL`, etc.) affect only the OS process that launched gemcode. Pasting `VAR=1` in chat does NOT reconfigure a running session—tell the user to export in their shell, use project `.env`, or restart the CLI.
264
- - **Working in subfolders** — call `list_directory("Desktop")`, `glob_files("**/query.ts")`, `read_file("testing/ai-edtech-app/src/app/page.tsx")` directly. Never claim access is blocked unless a tool returned an explicit error.{git_section}"""
280
+ - **Working in subfolders** — call `list_directory(\"Desktop\")`, `glob_files(\"**/query.ts\")`, `read_file(\"testing/ai-edtech-app/src/app/page.tsx\")` directly. Never claim access is blocked unless a tool returned an explicit error.{git_section}{curated_section}"""
265
281
 
266
282
 
267
283
  def _build_memory_section(cfg: GemCodeConfig) -> str:
@@ -664,7 +680,7 @@ Concrete patterns:
664
680
  - Grepping different patterns → multiple `grep_content` in one response
665
681
  - `list_directory` + `glob_files` → both at once
666
682
 
667
- **Parallel sub-agent exploration (OpenClaude pattern):**
683
+ **Parallel sub-agent exploration (reference terminal UI pattern):**
668
684
  When a task requires understanding several subsystems before acting:
669
685
  1. Spawn parallel `run_subtask` workers, one per subsystem
670
686
  2. Wait for all results to return in the same turn
@@ -828,7 +844,7 @@ Use `gh pr create` via `bash`. When asked to create a PR:
828
844
  All file tools use paths **relative to the project root** (where GemCode was started). The root may be the home folder — subfolders like `Desktop`, `Desktop/code`, `Documents` are inside the sandbox. Call `list_directory("Desktop")` or `glob_files("**/*name*.ts")` instead of assuming access is blocked. Only treat access as denied when a tool returns an explicit `error`.
829
845
 
830
846
  ## Agent notes (.gemcode/notes.md)
831
- You have two tools to persist project insights across sessions, like Claude Code's auto-memory:
847
+ You have two tools to persist project insights across sessions (auto-memory style):
832
848
 
833
849
  - **`append_project_note(note)`** — write a note to `.gemcode/notes.md`. Use this proactively when you discover something worth remembering:
834
850
  - Build/test/lint commands you discover ("Build: `npm run build` — requires Node 20")
@@ -840,7 +856,12 @@ You have two tools to persist project insights across sessions, like Claude Code
840
856
  Call this **immediately** when you discover something useful — not just at the end of tasks.
841
857
  Notes are loaded at session start so future sessions inherit this knowledge.
842
858
 
843
- - **`read_project_notes()`** — read current notes **only when starting a real engineering task** (editing, debugging, building). Do NOT call this for greetings or general questions. If notes exist and you're about to work on a task, read them once to avoid re-discovering known information."""
859
+ - **`read_project_notes()`** — read current notes **only when starting a real engineering task** (editing, debugging, building). Do NOT call this for greetings or general questions. If notes exist and you're about to work on a task, read them once to avoid re-discovering known information.
860
+
861
+ ## Do not create vendor-specific instruction files
862
+ - Do NOT create or modify `CLAUDE.md` or `AGENTS.md`. GemCode does not use these.
863
+ - If project instructions are needed and the user asked for it, use `GEMINI.md` (repo root).
864
+ """
844
865
 
845
866
  # Inject capability-specific strategy sections only when those caps are on.
846
867
  if getattr(cfg, "enable_computer_use", False):
@@ -855,6 +876,18 @@ You have two tools to persist project insights across sessions, like Claude Code
855
876
  tool_manifest = build_tool_manifest(cfg)
856
877
  if tool_manifest:
857
878
  base = f"{base}\n\n{tool_manifest}"
879
+ # Output style: small, user-selected formatting layer.
880
+ style_section = build_output_style_section(cfg.project_root, getattr(cfg, "output_style", None))
881
+ if style_section:
882
+ base = f"{base}\n\n{style_section}"
883
+ # Rules: project conventions (path-gated based on files the agent/user touched this session).
884
+ touched = sorted(getattr(cfg, "_touched_paths", set()) or set())
885
+ rules_section = build_rules_section(cfg.project_root, touched_paths=touched or None)
886
+ if rules_section:
887
+ base = f"{base}\n\n{rules_section}"
888
+ skill_manifest = build_skill_manifest_text(cfg.project_root)
889
+ if skill_manifest:
890
+ base = f"{base}\n\n{skill_manifest}"
858
891
  extra = _load_gemini_md(cfg.project_root)
859
892
  if extra.strip():
860
893
  return f"{base}\n\n## Project instructions (GEMINI.md)\n{extra}"
@@ -903,7 +936,7 @@ def build_root_agent(
903
936
  except Exception:
904
937
  pass
905
938
 
906
- # Agent auto-notes: write project insights to .gemcode/notes.md (Claude Code MEMORY.md equivalent)
939
+ # Agent auto-notes: write project insights to .gemcode/notes.md (project notes file)
907
940
  try:
908
941
  from gemcode.tools.notes import build_notes_tools
909
942
  notes_tools = build_notes_tools(cfg.project_root)
@@ -931,7 +964,7 @@ def build_root_agent(
931
964
  if before_model is not None:
932
965
  cb_kwargs["before_model_callback"] = before_model
933
966
 
934
- # Claude-like thinking: enabled by default (Gemini dynamic), but allow
967
+ # familiar thinking: enabled by default (Gemini dynamic), but allow
935
968
  # explicit overrides for disable/budgets/levels.
936
969
  gen_cfg = None
937
970
  thinking_cfg = build_thinking_config(cfg)
gemcode/autocompact.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """
2
- Claude Code–style autocompact for ADK/Gemini.
2
+ interactive CLI–style autocompact for ADK/Gemini.
3
3
 
4
4
  GemCode already has:
5
5
  - bounded tool output (after_tool truncation)
@@ -41,7 +41,7 @@ def _autocompact_enabled(cfg: GemCodeConfig) -> bool:
41
41
 
42
42
 
43
43
  def _autocompact_threshold_chars(cfg: GemCodeConfig) -> int:
44
- # Claude Code uses token windows; we use a character proxy budget since
44
+ # uses token windows; we use a character proxy budget since
45
45
  # Gemini tokenizers vary and ADK does not expose a cheap exact counter.
46
46
  max_chars = int(getattr(cfg, "max_context_chars", 0) or 0)
47
47
  if max_chars <= 0:
gemcode/autotune.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from gemcode.evals.harness import run_eval_suite, write_eval_record
9
+
10
+
11
+ def _sh(cmd: list[str], *, cwd: Path) -> tuple[int, str]:
12
+ p = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
13
+ out = (p.stdout or "") + (p.stderr or "")
14
+ return int(p.returncode), out
15
+
16
+
17
+ def _git_head_sha(repo: Path) -> str | None:
18
+ rc, out = _sh(["git", "rev-parse", "HEAD"], cwd=repo)
19
+ if rc != 0:
20
+ return None
21
+ return (out or "").strip().splitlines()[-1] if (out or "").strip() else None
22
+
23
+
24
+ def _git_branch(repo: Path) -> str | None:
25
+ rc, out = _sh(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo)
26
+ if rc != 0:
27
+ return None
28
+ return (out or "").strip().splitlines()[-1] if (out or "").strip() else None
29
+
30
+
31
+ def init_autotune(*, project_root: Path, tag: str) -> dict[str, Any]:
32
+ """
33
+ AutoResearch-style setup:
34
+ - create branch autotune/<tag> (if not exists)
35
+ - create results ledger under .gemcode/evals/
36
+ """
37
+ repo = project_root
38
+ if not (repo / ".git").exists():
39
+ return {"error": "not_a_git_repo"}
40
+ branch = f"autotune/{tag}"
41
+ rc, out = _sh(["git", "rev-parse", "--verify", branch], cwd=repo)
42
+ if rc == 0:
43
+ return {"status": "exists", "branch": branch}
44
+ rc2, out2 = _sh(["git", "checkout", "-b", branch], cwd=repo)
45
+ if rc2 != 0:
46
+ return {"error": "branch_create_failed", "output": out2[-1200:]}
47
+ return {"status": "created", "branch": branch}
48
+
49
+
50
+ def run_autotune_eval(*, project_root: Path, include_llm: bool, model: str | None = None) -> dict[str, Any]:
51
+ """
52
+ Run eval suite and persist last result to .gemcode/evals/last_eval.json.
53
+ """
54
+ res = run_eval_suite(project_root=project_root, include_llm=include_llm, model=model)
55
+ meta = {
56
+ "ts": time.time(),
57
+ "git_sha": _git_head_sha(project_root),
58
+ "git_branch": _git_branch(project_root),
59
+ }
60
+ p = write_eval_record(project_root, {**meta, **res})
61
+ res["record_path"] = str(p)
62
+
63
+ # Append ledger line (untracked; .gemcode/ is gitignored)
64
+ try:
65
+ ledger = project_root / ".gemcode" / "evals" / "autotune_ledger.jsonl"
66
+ ledger.parent.mkdir(parents=True, exist_ok=True)
67
+ import json
68
+
69
+ ledger.write_text("", encoding="utf-8") if not ledger.exists() else None
70
+ with ledger.open("a", encoding="utf-8") as f:
71
+ f.write(json.dumps({**meta, **res}, ensure_ascii=False) + "\n")
72
+ res["ledger_path"] = str(ledger)
73
+ except Exception:
74
+ pass
75
+ return res
76
+
gemcode/callbacks.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """
2
2
  ADK callbacks: permissions, audit, tool failure circuit breaker, usage logging.
3
3
 
4
- Maps to Claude Code patterns:
4
+ Maps to patterns:
5
5
  - before_tool / after_tool ≈ permission gates + telemetry around tool execution
6
6
  - after_model ≈ cost / usage hooks (see cost-tracker.ts role)
7
7
  - Session state for streak counters ≈ autoCompact failure tracking (MVP: tool errors)
@@ -60,7 +60,7 @@ def _truthy_env(name: str, *, default: bool = False) -> bool:
60
60
 
61
61
 
62
62
  def _maybe_tool_summary_enabled() -> bool:
63
- # Mirrors Claude's "emit tool use summaries" gate conceptually.
63
+ # Mirrors optional "emit tool use summaries" gating.
64
64
  return _truthy_env("GEMCODE_EMIT_TOOL_USE_SUMMARIES", default=False)
65
65
 
66
66
 
@@ -344,7 +344,7 @@ def make_before_tool_callback(cfg: GemCodeConfig):
344
344
 
345
345
 
346
346
  def make_after_tool_callback(cfg: GemCodeConfig):
347
- """Track consecutive tool failures in session state (Claude-style circuit breaker)."""
347
+ """Track consecutive tool failures in session state (conventional circuit breaker)."""
348
348
 
349
349
  def after_tool(
350
350
  tool: BaseTool,
@@ -548,7 +548,7 @@ def make_after_tool_callback(cfg: GemCodeConfig):
548
548
  summary[k] = v
549
549
  append_audit(cfg.project_root, summary)
550
550
  # Also print a concise, user-visible summary in CLI contexts.
551
- # (Claude Code renders tool cards; this is the lightweight equivalent.)
551
+ # (renders tool cards; this is the lightweight equivalent.)
552
552
  try:
553
553
  # Full-screen TUIs get corrupted by stray stderr prints.
554
554
  if _truthy_env("GEMCODE_TUI_ACTIVE", default=False):
@@ -621,7 +621,7 @@ def make_after_model_callback(cfg: GemCodeConfig):
621
621
 
622
622
  # ── Expose live token stats to the TUI ───────────────────────────────────
623
623
  # The TUI reads cfg._last_turn_stats after each turn to display token counts
624
- # and estimated cost in the footer (like OpenClaude's spinner token display).
624
+ # and estimated cost in the footer (like Reference UI spinner token display).
625
625
  try:
626
626
  in_tok = d.get("prompt_token_count", 0) or 0
627
627
  out_tok = d.get("candidates_token_count", 0) or 0
@@ -762,7 +762,7 @@ def make_after_model_callback(cfg: GemCodeConfig):
762
762
 
763
763
 
764
764
  def make_on_tool_error_callback(cfg: GemCodeConfig):
765
- """Turn tool exceptions into structured tool results (Claude-like is_error)."""
765
+ """Turn tool exceptions into structured tool results (familiar is_error)."""
766
766
 
767
767
  async def on_tool_error(
768
768
  *, tool: BaseTool, args: dict[str, Any], tool_context, error: Exception
@@ -1,8 +1,8 @@
1
1
  """
2
- Capability-based routing (Claude Code style conceptually).
2
+ Capability-based routing (style conceptually).
3
3
 
4
4
  This layer decides which *capabilities* to enable (deep research tools,
5
- embeddings retrieval, computer-use tools) and leaves the existing Claude-like
5
+ embeddings retrieval, computer-use tools) and leaves the existing familiar
6
6
  outer/inner loops intact.
7
7
 
8
8
  It is intentionally conservative:
gemcode/checkpoints.py ADDED
@@ -0,0 +1,144 @@
1
+ """
2
+ Hermes-style checkpoints for GemCode.
3
+
4
+ Goal: make file mutations reversible with an explicit, local checkpoint log.
5
+
6
+ Storage:
7
+ <project>/.gemcode/checkpoints/<checkpoint_id>/manifest.json
8
+ <project>/.gemcode/checkpoints/<checkpoint_id>/files/<path>
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+
20
+ def _now_ms() -> int:
21
+ return int(time.time() * 1000)
22
+
23
+
24
+ def _checkpoints_dir(project_root: Path) -> Path:
25
+ return project_root / ".gemcode" / "checkpoints"
26
+
27
+
28
+ def _safe_rel(project_root: Path, p: Path) -> str:
29
+ return str(p.resolve().relative_to(project_root.resolve()))
30
+
31
+
32
+ @dataclass
33
+ class CheckpointFile:
34
+ path: str
35
+ existed: bool
36
+
37
+
38
+ @dataclass
39
+ class Checkpoint:
40
+ id: str
41
+ ts_ms: int
42
+ op: str
43
+ files: list[CheckpointFile]
44
+
45
+
46
+ def create_checkpoint(
47
+ *,
48
+ project_root: Path,
49
+ op: str,
50
+ file_snapshots: list[tuple[Path, bool]],
51
+ ) -> Checkpoint:
52
+ """
53
+ Create a checkpoint capturing the *previous* contents of the provided files.
54
+
55
+ file_snapshots entries are (absolute_path, existed_bool).
56
+ """
57
+ ts = _now_ms()
58
+ cid = f"cp_{ts}"
59
+ base = _checkpoints_dir(project_root) / cid
60
+ files_dir = base / "files"
61
+ files_dir.mkdir(parents=True, exist_ok=True)
62
+ out_files: list[CheckpointFile] = []
63
+
64
+ for abs_path, existed in file_snapshots:
65
+ try:
66
+ rel = _safe_rel(project_root, abs_path)
67
+ except Exception:
68
+ continue
69
+ out_files.append(CheckpointFile(path=rel, existed=bool(existed)))
70
+ if existed and abs_path.is_file():
71
+ target = files_dir / rel
72
+ target.parent.mkdir(parents=True, exist_ok=True)
73
+ target.write_bytes(abs_path.read_bytes())
74
+
75
+ manifest = {
76
+ "id": cid,
77
+ "ts_ms": ts,
78
+ "op": op,
79
+ "files": [{"path": f.path, "existed": f.existed} for f in out_files],
80
+ }
81
+ (base / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
82
+ return Checkpoint(id=cid, ts_ms=ts, op=op, files=out_files)
83
+
84
+
85
+ def list_checkpoints(project_root: Path, limit: int = 20) -> list[dict[str, Any]]:
86
+ d = _checkpoints_dir(project_root)
87
+ if not d.is_dir():
88
+ return []
89
+ cps = []
90
+ for p in sorted(d.iterdir(), key=lambda x: x.name, reverse=True):
91
+ m = p / "manifest.json"
92
+ if not m.is_file():
93
+ continue
94
+ try:
95
+ obj = json.loads(m.read_text(encoding="utf-8"))
96
+ cps.append(obj)
97
+ except Exception:
98
+ continue
99
+ if len(cps) >= max(1, int(limit)):
100
+ break
101
+ return cps
102
+
103
+
104
+ def undo_checkpoint(project_root: Path, checkpoint_id: str | None = None) -> dict[str, Any]:
105
+ d = _checkpoints_dir(project_root)
106
+ if not d.is_dir():
107
+ return {"error": "no_checkpoints"}
108
+ if checkpoint_id:
109
+ base = d / checkpoint_id
110
+ else:
111
+ # newest
112
+ items = [p for p in d.iterdir() if p.is_dir()]
113
+ if not items:
114
+ return {"error": "no_checkpoints"}
115
+ base = sorted(items, key=lambda x: x.name, reverse=True)[0]
116
+ manifest_path = base / "manifest.json"
117
+ if not manifest_path.is_file():
118
+ return {"error": "checkpoint_missing_manifest"}
119
+ try:
120
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
121
+ except Exception as e:
122
+ return {"error": f"checkpoint_manifest_invalid:{e}"}
123
+ files_dir = base / "files"
124
+ restored = []
125
+ for f in manifest.get("files") or []:
126
+ try:
127
+ rel = str(f.get("path") or "")
128
+ existed = bool(f.get("existed"))
129
+ abs_path = (project_root / rel).resolve()
130
+ if existed:
131
+ src = files_dir / rel
132
+ if src.is_file():
133
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
134
+ abs_path.write_bytes(src.read_bytes())
135
+ restored.append(rel)
136
+ else:
137
+ # File did not exist previously; remove it if it exists now.
138
+ if abs_path.is_file():
139
+ abs_path.unlink()
140
+ restored.append(rel)
141
+ except Exception:
142
+ continue
143
+ return {"checkpoint_id": manifest.get("id") or base.name, "restored": restored}
144
+