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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- 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,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()
|