crabcode 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 (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. crabcode_search/tool.py +232 -0
@@ -0,0 +1,150 @@
1
+ """Conversation compaction — summarizes old messages to free context space."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from crabcode_core.api.base import APIAdapter, ModelConfig
8
+ from crabcode_core.types.message import (
9
+ Message,
10
+ MessageRole,
11
+ TextBlock,
12
+ create_user_message,
13
+ create_assistant_message,
14
+ )
15
+
16
+
17
+ COMPACT_PROMPT = """Summarize the conversation so far concisely. Focus on:
18
+ 1. What the user asked for
19
+ 2. What actions were taken (files edited, commands run, tools used)
20
+ 3. Current state of the task (what's done, what's pending)
21
+ 4. Key findings or decisions made
22
+
23
+ Be concise but preserve all information needed to continue the conversation."""
24
+
25
+ DEFAULT_COMPACT_THRESHOLD = 100_000
26
+ AUTOCOMPACT_BUFFER_TOKENS = 13_000
27
+
28
+
29
+ def estimate_token_count(messages: list[Message]) -> int:
30
+ """Rough token estimate: ~4 chars per token."""
31
+ total = 0
32
+ for msg in messages:
33
+ if isinstance(msg.content, str):
34
+ total += len(msg.content)
35
+ else:
36
+ for block in msg.content:
37
+ if isinstance(block, TextBlock):
38
+ total += len(block.text)
39
+ elif hasattr(block, "content"):
40
+ total += len(getattr(block, "content", ""))
41
+ return total // 4
42
+
43
+
44
+ def should_auto_compact(
45
+ messages: list[Message],
46
+ threshold: int = DEFAULT_COMPACT_THRESHOLD,
47
+ ) -> bool:
48
+ """Check if conversation should be auto-compacted."""
49
+ if len(messages) < 4:
50
+ return False
51
+ estimated_tokens = estimate_token_count(messages)
52
+ return estimated_tokens > (threshold - AUTOCOMPACT_BUFFER_TOKENS)
53
+
54
+
55
+ async def compact_conversation(
56
+ messages: list[Message],
57
+ api_adapter: APIAdapter | None = None,
58
+ custom_summary: str | None = None,
59
+ ) -> list[Message] | None:
60
+ """Compact a conversation by summarizing old messages.
61
+
62
+ Returns new message list with summary, or None if compaction failed.
63
+ """
64
+ if len(messages) < 4:
65
+ return None
66
+
67
+ if custom_summary:
68
+ summary = custom_summary
69
+ elif api_adapter:
70
+ summary = await _generate_summary(messages, api_adapter)
71
+ else:
72
+ summary = _fallback_summary(messages)
73
+
74
+ if not summary:
75
+ return None
76
+
77
+ summary_msg = create_user_message(content=f"[Conversation summary: {summary}]")
78
+ summary_msg.is_compact_summary = True
79
+
80
+ recent_count = min(4, len(messages))
81
+ recent = messages[-recent_count:]
82
+
83
+ return [summary_msg, *recent]
84
+
85
+
86
+ async def _generate_summary(
87
+ messages: list[Message],
88
+ api_adapter: APIAdapter,
89
+ ) -> str:
90
+ """Use the API to generate a conversation summary."""
91
+ try:
92
+ conversation_text = ""
93
+ for msg in messages:
94
+ role = msg.role.value
95
+ text = msg.text_content[:2000] if msg.text_content else ""
96
+ if text:
97
+ conversation_text += f"{role}: {text}\n\n"
98
+
99
+ if len(conversation_text) > 50_000:
100
+ conversation_text = conversation_text[:50_000] + "\n... (truncated)"
101
+
102
+ summary_messages = [
103
+ create_user_message(
104
+ content=f"{COMPACT_PROMPT}\n\nConversation:\n{conversation_text}"
105
+ ),
106
+ ]
107
+
108
+ config = ModelConfig(
109
+ max_tokens=2048,
110
+ thinking_enabled=False,
111
+ )
112
+
113
+ summary_parts: list[str] = []
114
+ async for chunk in api_adapter.stream_message(
115
+ messages=summary_messages,
116
+ system=["You are a helpful assistant that summarizes conversations concisely."],
117
+ tools=[],
118
+ config=config,
119
+ ):
120
+ if chunk.type == "text":
121
+ summary_parts.append(chunk.text)
122
+
123
+ return "".join(summary_parts)
124
+
125
+ except Exception:
126
+ return _fallback_summary(messages)
127
+
128
+
129
+ def _fallback_summary(messages: list[Message]) -> str:
130
+ """Generate a simple summary without API access."""
131
+ parts: list[str] = []
132
+
133
+ user_msgs = [m for m in messages if m.role == MessageRole.USER and m.text_content]
134
+ if user_msgs:
135
+ first = user_msgs[0].text_content[:200]
136
+ parts.append(f"User initially asked: {first}")
137
+
138
+ tool_names: set[str] = set()
139
+ for msg in messages:
140
+ if not isinstance(msg.content, str):
141
+ for block in msg.content:
142
+ if hasattr(block, "name"):
143
+ tool_names.add(getattr(block, "name"))
144
+
145
+ if tool_names:
146
+ parts.append(f"Tools used: {', '.join(sorted(tool_names))}")
147
+
148
+ parts.append(f"Total messages: {len(messages)}")
149
+
150
+ return " | ".join(parts)
@@ -0,0 +1,3 @@
1
+ from crabcode_core.config.manager import ConfigManager
2
+
3
+ __all__ = ["ConfigManager"]
@@ -0,0 +1,53 @@
1
+ """CLAUDE.md discovery — finds and loads CLAUDE.md files from the project hierarchy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ def discover_claude_md(cwd: str) -> list[dict[str, str]]:
10
+ """Discover all CLAUDE.md files in the project hierarchy.
11
+
12
+ Returns list of dicts with 'path' and 'content' keys,
13
+ ordered from most general (home) to most specific (project root).
14
+ """
15
+ results: list[dict[str, str]] = []
16
+ home = Path.home()
17
+
18
+ for config_dir in [".claude", ".crabcode"]:
19
+ home_md = home / config_dir / "CLAUDE.md"
20
+ if home_md.exists():
21
+ try:
22
+ content = home_md.read_text(errors="replace")
23
+ results.append({"path": str(home_md), "content": content})
24
+ except Exception:
25
+ pass
26
+
27
+ search_dir = Path(cwd).resolve()
28
+ project_files: list[dict[str, str]] = []
29
+
30
+ current = search_dir
31
+ while current != current.parent:
32
+ for name in ["CLAUDE.md", ".claude/CLAUDE.md", ".crabcode/CLAUDE.md"]:
33
+ candidate = current / name
34
+ if candidate.exists():
35
+ try:
36
+ content = candidate.read_text(errors="replace")
37
+ project_files.append({"path": str(candidate), "content": content})
38
+ except Exception:
39
+ pass
40
+ if (current / ".git").exists():
41
+ break
42
+ current = current.parent
43
+
44
+ results.extend(reversed(project_files))
45
+ return results
46
+
47
+
48
+ def load_claude_md_as_string(cwd: str) -> str | None:
49
+ """Load all CLAUDE.md files and join them into a single string."""
50
+ files = discover_claude_md(cwd)
51
+ if not files:
52
+ return None
53
+ return "\n\n---\n\n".join(f["content"] for f in files)
@@ -0,0 +1,152 @@
1
+ """Configuration manager — 5-layer settings merge with Pydantic validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from crabcode_core.types.config import CrabCodeSettings
11
+
12
+
13
+ SETTING_SOURCES = [
14
+ "userSettings",
15
+ "projectSettings",
16
+ "localSettings",
17
+ "flagSettings",
18
+ "policySettings",
19
+ ]
20
+
21
+
22
+ def _merge_settings(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
23
+ """Deep merge two settings dicts. Arrays are concatenated and deduped."""
24
+ result = deepcopy(base)
25
+ for key, value in override.items():
26
+ if key in result:
27
+ if isinstance(result[key], list) and isinstance(value, list):
28
+ seen: set[str] = set()
29
+ merged: list[Any] = []
30
+ for item in result[key] + value:
31
+ item_key = json.dumps(item, sort_keys=True) if isinstance(item, dict) else str(item)
32
+ if item_key not in seen:
33
+ seen.add(item_key)
34
+ merged.append(item)
35
+ result[key] = merged
36
+ elif isinstance(result[key], dict) and isinstance(value, dict):
37
+ result[key] = _merge_settings(result[key], value)
38
+ else:
39
+ result[key] = deepcopy(value)
40
+ else:
41
+ result[key] = deepcopy(value)
42
+ return result
43
+
44
+
45
+ class ConfigManager:
46
+ """Manages CrabCode settings from multiple sources.
47
+
48
+ Merge order (later overrides earlier):
49
+ userSettings -> projectSettings -> localSettings -> flagSettings -> policySettings
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ cwd: str = ".",
55
+ flag_settings_path: str | None = None,
56
+ ):
57
+ self._cwd = cwd
58
+ self._flag_settings_path = flag_settings_path
59
+ self._cache: CrabCodeSettings | None = None
60
+
61
+ @property
62
+ def settings_file_paths(self) -> dict[str, str | None]:
63
+ home = Path.home() / ".crabcode"
64
+ project = Path(self._cwd).resolve()
65
+ return {
66
+ "userSettings": str(home / "settings.json"),
67
+ "projectSettings": str(project / ".crabcode" / "settings.json"),
68
+ "localSettings": str(project / ".crabcode" / "settings.local.json"),
69
+ "flagSettings": self._flag_settings_path,
70
+ "policySettings": str(home / "managed-settings.json"),
71
+ }
72
+
73
+ def load(self) -> CrabCodeSettings:
74
+ """Load and merge all settings layers."""
75
+ merged: dict[str, Any] = {}
76
+
77
+ for source in SETTING_SOURCES:
78
+ path_str = self.settings_file_paths.get(source)
79
+ if not path_str:
80
+ continue
81
+
82
+ path = Path(path_str)
83
+ if not path.exists():
84
+ continue
85
+
86
+ try:
87
+ raw = json.loads(path.read_text(errors="replace"))
88
+ if isinstance(raw, dict):
89
+ merged = _merge_settings(merged, raw)
90
+ except (json.JSONDecodeError, OSError):
91
+ continue
92
+
93
+ try:
94
+ self._cache = CrabCodeSettings.model_validate(merged)
95
+ except Exception:
96
+ self._cache = CrabCodeSettings()
97
+
98
+ return self._cache
99
+
100
+ def get(self) -> CrabCodeSettings:
101
+ """Get cached settings or load from disk."""
102
+ if self._cache is None:
103
+ return self.load()
104
+ return self._cache
105
+
106
+ def reset_cache(self) -> None:
107
+ """Clear the cached settings."""
108
+ self._cache = None
109
+
110
+ def get_settings_for_source(self, source: str) -> dict[str, Any] | None:
111
+ """Get raw settings from a single source."""
112
+ path_str = self.settings_file_paths.get(source)
113
+ if not path_str:
114
+ return None
115
+
116
+ path = Path(path_str)
117
+ if not path.exists():
118
+ return None
119
+
120
+ try:
121
+ raw = json.loads(path.read_text(errors="replace"))
122
+ return raw if isinstance(raw, dict) else None
123
+ except (json.JSONDecodeError, OSError):
124
+ return None
125
+
126
+ def update_settings(
127
+ self,
128
+ source: str,
129
+ settings: dict[str, Any],
130
+ ) -> None:
131
+ """Update settings for a given source."""
132
+ if source in ("policySettings", "flagSettings"):
133
+ return
134
+
135
+ path_str = self.settings_file_paths.get(source)
136
+ if not path_str:
137
+ return
138
+
139
+ path = Path(path_str)
140
+ path.parent.mkdir(parents=True, exist_ok=True)
141
+
142
+ existing: dict[str, Any] = {}
143
+ if path.exists():
144
+ try:
145
+ existing = json.loads(path.read_text(errors="replace"))
146
+ except (json.JSONDecodeError, OSError):
147
+ pass
148
+
149
+ merged = _merge_settings(existing, settings)
150
+ path.write_text(json.dumps(merged, indent=2, ensure_ascii=False) + "\n")
151
+
152
+ self.reset_cache()