limbo-code 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.
limbo/tools/ignore.py ADDED
@@ -0,0 +1,111 @@
1
+ """Gitignore matching for file tools.
2
+
3
+ Shared by ``find`` and ``grep``: a minimal matcher that reads the workdir
4
+ root ``.gitignore``. Does not support nested .gitignore files or advanced
5
+ glob/negation rules.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from pathlib import Path
12
+
13
+
14
+ class GitignoreMatcher:
15
+ """Minimal gitignore matcher that reads the workdir root `.gitignore`."""
16
+
17
+ def __init__(self, base_dir: Path) -> None:
18
+ self.rules: list[tuple[bool, re.Pattern[str], bool]] = []
19
+ gitignore = base_dir / ".gitignore"
20
+ if gitignore.is_file():
21
+ try:
22
+ text = gitignore.read_text(encoding="utf-8")
23
+ except OSError:
24
+ text = ""
25
+ for line in text.splitlines():
26
+ rule = self._parse_line(line)
27
+ if rule is not None:
28
+ self.rules.append(rule)
29
+
30
+ def _parse_line(self, line: str) -> tuple[bool, re.Pattern[str], bool] | None:
31
+ line = line.rstrip("\n")
32
+ if not line or line.startswith("#"):
33
+ return None
34
+
35
+ negation = False
36
+ if line.startswith("!"):
37
+ negation = True
38
+ line = line[1:]
39
+ if not line:
40
+ return None
41
+ elif line.startswith("\\!"):
42
+ line = line[1:]
43
+
44
+ if line.startswith("#"):
45
+ return None
46
+
47
+ dir_only = line.endswith("/")
48
+ if dir_only:
49
+ line = line[:-1]
50
+
51
+ anchored = "/" in line
52
+ if line.startswith("/"):
53
+ line = line[1:]
54
+
55
+ regex = self._glob_to_regex(line)
56
+ if not anchored:
57
+ regex = f"(?:.*/)?{regex}"
58
+ pattern = re.compile(f"^{regex}$")
59
+ return (negation, pattern, dir_only)
60
+
61
+ def _glob_to_regex(self, pattern: str) -> str:
62
+ i = 0
63
+ n = len(pattern)
64
+ result: list[str] = []
65
+ while i < n:
66
+ c = pattern[i]
67
+ if c == "*" and i + 1 < n and pattern[i + 1] == "*":
68
+ result.append(".*")
69
+ i += 2
70
+ elif c == "*":
71
+ result.append("[^/]*")
72
+ i += 1
73
+ elif c == "?":
74
+ result.append("[^/]")
75
+ i += 1
76
+ elif c == "[":
77
+ j = i + 1
78
+ if j < n and pattern[j] == "!":
79
+ j += 1
80
+ if j < n and pattern[j] == "]":
81
+ j += 1
82
+ while j < n and pattern[j] != "]":
83
+ j += 1
84
+ if j >= n:
85
+ result.append(re.escape(c))
86
+ i += 1
87
+ else:
88
+ chars = pattern[i + 1 : j]
89
+ if chars.startswith("!"):
90
+ chars = f"^{chars[1:]}"
91
+ result.append(f"[{chars}]")
92
+ i = j + 1
93
+ else:
94
+ result.append(re.escape(c))
95
+ i += 1
96
+ return "".join(result)
97
+
98
+ def is_ignored(self, rel_path: str) -> bool:
99
+ parts = rel_path.split("/")
100
+ paths = [rel_path]
101
+ paths.extend("/".join(parts[:k]) for k in range(1, len(parts)))
102
+
103
+ ignored = False
104
+ for negation, regex, dir_only in self.rules:
105
+ for path in paths:
106
+ if dir_only and path == rel_path:
107
+ continue
108
+ if regex.match(path):
109
+ ignored = not negation
110
+ break
111
+ return ignored
limbo/tools/ls.py ADDED
@@ -0,0 +1,41 @@
1
+ """List directory contents tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from limbo.models import ToolResult
8
+ from limbo.tools.base import BaseTool
9
+
10
+ MAX_ENTRIES = 500
11
+
12
+
13
+ class LsTool(BaseTool):
14
+ name = "ls"
15
+ description = "List directory contents. Includes dotfiles."
16
+ parameters = {
17
+ "type": "object",
18
+ "properties": {
19
+ "path": {"type": "string", "description": "Directory to list"},
20
+ "limit": {"type": "integer", "description": "Max entries"},
21
+ },
22
+ "required": [],
23
+ }
24
+
25
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
26
+ path = arguments.get("path", ".")
27
+ limit = arguments.get("limit", MAX_ENTRIES)
28
+
29
+ target = self.resolve_existing(path, kind="dir")
30
+
31
+ try:
32
+ entries = sorted(target.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
33
+ except OSError as e:
34
+ return ToolResult(success=False, error=f"Could not list directory: {e}")
35
+
36
+ lines = []
37
+ for entry in entries[:limit]:
38
+ suffix = "/" if entry.is_dir() else ""
39
+ lines.append(f"{entry.name}{suffix}")
40
+
41
+ return ToolResult(success=True, output="\n".join(lines))
limbo/tools/read.py ADDED
@@ -0,0 +1,105 @@
1
+ """Read file contents tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from limbo.config import DEFAULT_SENSITIVE_FILES
9
+ from limbo.models import ToolResult
10
+ from limbo.tools.base import MAX_OUTPUT_BYTES, BaseTool, ToolError
11
+
12
+ MAX_LINES = 2000
13
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
14
+
15
+
16
+ class ReadTool(BaseTool):
17
+ name = "read"
18
+ description = "Read the contents of a file. Use offset/limit for large files."
19
+ parameters = {
20
+ "type": "object",
21
+ "properties": {
22
+ "path": {"type": "string", "description": "Path to the file"},
23
+ "offset": {
24
+ "type": "integer",
25
+ "description": "Line number to start from (1-indexed, optional)",
26
+ },
27
+ "limit": {
28
+ "type": "integer",
29
+ "description": "Maximum lines to read (optional)",
30
+ },
31
+ },
32
+ "required": ["path"],
33
+ }
34
+
35
+ def __init__(
36
+ self, workdir: Path, sensitive_files: list[str] | None = None
37
+ ):
38
+ super().__init__(workdir)
39
+ self.sensitive_files = set(sensitive_files or DEFAULT_SENSITIVE_FILES)
40
+
41
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
42
+ raw_path = arguments.get("path", "")
43
+ target = self.resolve(raw_path)
44
+
45
+ if target.name in self.sensitive_files or any(
46
+ part in self.sensitive_files for part in target.parts
47
+ ):
48
+ return ToolResult(success=False, error="Refusing to read sensitive file.")
49
+
50
+ if not target.exists():
51
+ raise ToolError(f"File not found: {raw_path}")
52
+ if not target.is_file():
53
+ raise ToolError(f"Not a file: {raw_path}")
54
+
55
+ try:
56
+ file_size = target.stat().st_size
57
+ except OSError as e:
58
+ return ToolResult(success=False, error=f"Could not read file: {e}")
59
+ if file_size > MAX_FILE_SIZE:
60
+ return ToolResult(
61
+ success=False,
62
+ error=(
63
+ f"File too large ({file_size} bytes). "
64
+ f"Maximum size is {MAX_FILE_SIZE} bytes."
65
+ ),
66
+ )
67
+
68
+ try:
69
+ text = target.read_text(encoding="utf-8", errors="replace")
70
+ except OSError as e:
71
+ return ToolResult(success=False, error=f"Could not read file: {e}")
72
+
73
+ lines = text.splitlines(keepends=True)
74
+ offset = arguments.get("offset")
75
+ limit = arguments.get("limit")
76
+
77
+ if offset is not None and offset < 1:
78
+ return ToolResult(
79
+ success=False, error="offset must be a positive integer."
80
+ )
81
+ if limit is not None and limit < 1:
82
+ return ToolResult(
83
+ success=False, error="limit must be a positive integer."
84
+ )
85
+
86
+ if offset is not None:
87
+ start = max(0, offset - 1)
88
+ lines = lines[start:]
89
+ if limit is not None:
90
+ lines = lines[:limit]
91
+
92
+ output = "".join(lines)
93
+ truncated = False
94
+ encoded = output.encode("utf-8", errors="replace")
95
+ if len(encoded) > MAX_OUTPUT_BYTES:
96
+ output = encoded[:MAX_OUTPUT_BYTES].decode("utf-8", errors="replace")
97
+ truncated = True
98
+ elif len(lines) > MAX_LINES:
99
+ output = "".join(lines[:MAX_LINES])
100
+ truncated = True
101
+
102
+ if truncated:
103
+ output += "\n[Output truncated. Use offset/limit to read more.]"
104
+
105
+ return ToolResult(success=True, output=output)
@@ -0,0 +1,66 @@
1
+ """Tool registry that collects all available tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from limbo.config import Config
10
+ from limbo.models import ToolResult
11
+ from limbo.tools.base import BaseTool
12
+ from limbo.tools.bash import BashTool
13
+ from limbo.tools.edit import EditTool
14
+ from limbo.tools.find import FindTool
15
+ from limbo.tools.grep import GrepTool
16
+ from limbo.tools.ls import LsTool
17
+ from limbo.tools.read import ReadTool
18
+ from limbo.tools.write import WriteTool
19
+
20
+
21
+ class ToolRegistry:
22
+ """Registers and executes tools."""
23
+
24
+ def __init__(self, workdir: Path, config: Config | None = None):
25
+ self.workdir = workdir
26
+ self.config = config or Config()
27
+ self._tools: dict[str, BaseTool] = {}
28
+ # Tools without configurable safety settings are registered generically.
29
+ for tool_class in [EditTool, WriteTool, GrepTool, FindTool, LsTool]:
30
+ self.register(tool_class) # type: ignore[type-abstract]
31
+ # Wire configurable safety options from Config.
32
+ self._tools["read"] = ReadTool(
33
+ workdir=workdir,
34
+ sensitive_files=self.config.safety.sensitive_files,
35
+ )
36
+ if self.config.tools.bash_enabled:
37
+ self._tools["bash"] = BashTool(
38
+ workdir=workdir,
39
+ dangerous_patterns=self.config.safety.dangerous_commands,
40
+ )
41
+
42
+ def register(self, tool_class: type[BaseTool]) -> None:
43
+ tool = tool_class(workdir=self.workdir)
44
+ self._tools[tool.name] = tool
45
+
46
+ def get(self, name: str) -> BaseTool | None:
47
+ return self._tools.get(name)
48
+
49
+ async def execute(self, name: str, arguments: dict[str, Any]) -> ToolResult:
50
+ tool = self.get(name)
51
+ if tool is None:
52
+ return ToolResult(success=False, error=f"Unknown tool: {name}")
53
+ return await asyncio.to_thread(tool.execute, arguments)
54
+
55
+ def definitions(self) -> list[dict[str, Any]]:
56
+ return [
57
+ {
58
+ "type": "function",
59
+ "function": {
60
+ "name": tool.name,
61
+ "description": tool.description,
62
+ "parameters": tool.parameters,
63
+ },
64
+ }
65
+ for tool in self._tools.values()
66
+ ]
limbo/tools/write.py ADDED
@@ -0,0 +1,34 @@
1
+ """Write file contents tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from limbo.models import ToolResult
8
+ from limbo.tools.base import BaseTool
9
+
10
+
11
+ class WriteTool(BaseTool):
12
+ name = "write"
13
+ description = "Create or overwrite a file. Use only for new files or complete rewrites."
14
+ parameters = {
15
+ "type": "object",
16
+ "properties": {
17
+ "path": {"type": "string", "description": "Path to the file"},
18
+ "content": {"type": "string", "description": "Content to write"},
19
+ },
20
+ "required": ["path", "content"],
21
+ }
22
+
23
+ def run(self, arguments: dict[str, Any]) -> ToolResult:
24
+ raw_path = arguments.get("path", "")
25
+ content = arguments.get("content", "")
26
+ target = self.resolve_creatable(raw_path)
27
+
28
+ try:
29
+ target.parent.mkdir(parents=True, exist_ok=True)
30
+ target.write_text(content, encoding="utf-8")
31
+ except OSError as e:
32
+ return ToolResult(success=False, error=f"Could not write file: {e}")
33
+
34
+ return ToolResult(success=True, output=f"Wrote {raw_path}.")
limbo/trace.py ADDED
@@ -0,0 +1,100 @@
1
+ """Append-only JSONL trace log for analyzing complete agent runs.
2
+
3
+ The trace lives in a ``traces/`` subdirectory of the session directory
4
+ (``traces/<session>.trace.jsonl``) and records everything the message
5
+ history cannot reconstruct after the fact: full LLM request bodies,
6
+ token usage, tool execution timing, and errors/interruptions.
7
+
8
+ Writes are appended and flushed immediately so a crash or interrupt loses
9
+ at most the in-flight event. Tracing must never break the agent: all write
10
+ failures degrade to a warning.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ import warnings
18
+ from datetime import datetime, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ TRACE_SUFFIX = ".trace.jsonl"
23
+
24
+
25
+ def trace_path_for(session_file: Path) -> Path:
26
+ """Return the trace file path belonging to a session file.
27
+
28
+ Traces live in a ``traces/`` subdirectory so session listing/globbing
29
+ (``*.jsonl``) never picks them up.
30
+ """
31
+ return session_file.parent / "traces" / f"{session_file.stem}{TRACE_SUFFIX}"
32
+
33
+
34
+ def _utc_now_iso() -> str:
35
+ return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
36
+
37
+
38
+ class TraceLogger:
39
+ """Thread-safe, append-only JSONL event log (one record per line)."""
40
+
41
+ def __init__(self, path: Path):
42
+ self.path = path
43
+ self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
44
+ file_existed = self.path.exists()
45
+ self._file = self.path.open("a", encoding="utf-8")
46
+ if not file_existed:
47
+ self.path.chmod(0o600)
48
+ self._lock = threading.Lock()
49
+ self._failed = False
50
+
51
+ def log(self, event_type: str, **fields: Any) -> None:
52
+ """Append one event record. Never raises."""
53
+ record = {"ts": _utc_now_iso(), "type": event_type, **fields}
54
+ try:
55
+ line = json.dumps(record, ensure_ascii=False, default=str)
56
+ except (TypeError, ValueError) as e:
57
+ # Extremely defensive: `default=str` should catch everything.
58
+ line = json.dumps(
59
+ {
60
+ "ts": record["ts"],
61
+ "type": event_type,
62
+ "serialization_error": str(e),
63
+ },
64
+ ensure_ascii=False,
65
+ )
66
+ try:
67
+ with self._lock:
68
+ self._file.write(line + "\n")
69
+ self._file.flush()
70
+ except OSError as e:
71
+ if not self._failed:
72
+ self._failed = True
73
+ warnings.warn(f"Trace log write failed: {e}", stacklevel=2)
74
+
75
+ def close(self) -> None:
76
+ try:
77
+ with self._lock:
78
+ self._file.close()
79
+ except OSError:
80
+ pass
81
+
82
+
83
+ def read_trace(path: Path) -> list[dict[str, Any]]:
84
+ """Read all trace records, skipping malformed lines."""
85
+ records: list[dict[str, Any]] = []
86
+ try:
87
+ with path.open("r", encoding="utf-8") as f:
88
+ for line in f:
89
+ line = line.strip()
90
+ if not line:
91
+ continue
92
+ try:
93
+ record = json.loads(line)
94
+ except json.JSONDecodeError:
95
+ continue
96
+ if isinstance(record, dict):
97
+ records.append(record)
98
+ except OSError:
99
+ pass
100
+ return records
limbo/ui/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Limbo TUI components."""
limbo/ui/app.py ADDED
@@ -0,0 +1,52 @@
1
+ """Textual App for Limbo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from textual.app import App
8
+
9
+ from limbo.config import Config
10
+ from limbo.llm.client import LLMClient
11
+ from limbo.ui.screens.main import MainScreen
12
+
13
+
14
+ class LimboApp(App[None]):
15
+ """Main TUI application."""
16
+
17
+ TITLE = "Limbo"
18
+ CSS_PATH = "app.tcss"
19
+
20
+ def __init__(
21
+ self,
22
+ workdir: str | Path,
23
+ config: Config | None = None,
24
+ llm_client: LLMClient | None = None,
25
+ session_dir: Path | None = None,
26
+ resume: Path | None = None,
27
+ *args,
28
+ **kwargs,
29
+ ):
30
+ super().__init__(*args, **kwargs)
31
+ self.workdir = Path(workdir).resolve()
32
+ self.config = config
33
+ self.llm_client = llm_client
34
+ self.session_dir = session_dir
35
+ self.resume = resume
36
+ theme = (config.ui.theme if config else None) or None
37
+ if theme:
38
+ try:
39
+ self.theme = theme
40
+ except Exception: # noqa: BLE001 - unknown theme name, keep default
41
+ pass
42
+
43
+ def on_mount(self) -> None:
44
+ self.push_screen(
45
+ MainScreen(
46
+ workdir=self.workdir,
47
+ config=self.config,
48
+ llm_client=self.llm_client,
49
+ session_dir=self.session_dir,
50
+ resume=self.resume,
51
+ )
52
+ )
limbo/ui/app.tcss ADDED
@@ -0,0 +1,174 @@
1
+ /* Limbo TUI — pi 风格单栏布局,集中管理所有样式 */
2
+
3
+ /* ---- 顶部状态栏 ---- */
4
+ #statusbar {
5
+ height: 1;
6
+ padding: 0 1;
7
+ background: $surface;
8
+ }
9
+ #statusbar-state {
10
+ width: 1fr;
11
+ color: $text-muted;
12
+ }
13
+ #statusbar-state.thinking {
14
+ color: $warning;
15
+ }
16
+ #statusbar-state.tool {
17
+ color: $accent;
18
+ }
19
+ #statusbar-context {
20
+ width: auto;
21
+ color: $text-muted;
22
+ text-align: right;
23
+ }
24
+
25
+ /* ---- 聊天流 ---- */
26
+ ChatWidget {
27
+ width: 1fr;
28
+ height: 1fr;
29
+ padding: 0 1;
30
+ }
31
+ .user-message {
32
+ color: $text-accent;
33
+ margin-top: 1;
34
+ }
35
+ .assistant-message {
36
+ margin-top: 1;
37
+ padding: 0;
38
+ }
39
+ .error-message {
40
+ color: $error;
41
+ margin-top: 1;
42
+ }
43
+ .info-message {
44
+ color: $text-muted;
45
+ }
46
+ .ascii-art {
47
+ color: $text-muted;
48
+ margin-top: 1;
49
+ }
50
+
51
+ /* ---- 工具调用卡片 ---- */
52
+ ToolCard {
53
+ height: auto;
54
+ padding-left: 1;
55
+ }
56
+ ToolCard .tool-header {
57
+ height: 1;
58
+ color: $text-muted;
59
+ }
60
+ ToolCard.running .tool-header {
61
+ color: $warning;
62
+ }
63
+ ToolCard.success .tool-header {
64
+ color: $success;
65
+ }
66
+ ToolCard.error .tool-header {
67
+ color: $error;
68
+ }
69
+ ToolCard .tool-body {
70
+ height: auto;
71
+ max-height: 24;
72
+ padding-left: 1;
73
+ border-left: solid $primary-lighten-2;
74
+ }
75
+
76
+ /* ---- 斜杠命令自动补全菜单 ---- */
77
+ #slash-menu {
78
+ height: auto;
79
+ max-height: 8;
80
+ margin: 0 1;
81
+ border: round $primary;
82
+ background: $surface;
83
+ }
84
+
85
+ /* ---- 输入框(全局唯一的圆角边框,视觉锚点) ---- */
86
+ #input {
87
+ height: 3;
88
+ border: round $accent;
89
+ margin: 0 1;
90
+ }
91
+ #input:focus {
92
+ border: round $accent-lighten-1;
93
+ }
94
+
95
+ /* ---- 底部快捷键提示 ---- */
96
+ #hint {
97
+ height: 1;
98
+ padding: 0 2;
99
+ color: $text-muted;
100
+ }
101
+
102
+ /* ---- 会话选择弹窗 ---- */
103
+ SessionPicker {
104
+ align: center middle;
105
+ }
106
+ SessionPicker #session-picker {
107
+ width: 80%;
108
+ max-width: 110;
109
+ height: auto;
110
+ max-height: 80%;
111
+ border: round $accent;
112
+ background: $surface;
113
+ padding: 1 2;
114
+ }
115
+ SessionPicker #picker-title {
116
+ text-style: bold;
117
+ margin-bottom: 1;
118
+ }
119
+ SessionPicker ListView {
120
+ height: auto;
121
+ max-height: 20;
122
+ }
123
+
124
+ /* ---- 2048 游戏弹窗 ---- */
125
+ Game2048Screen {
126
+ align: center middle;
127
+ }
128
+ Game2048Screen #game-container {
129
+ width: auto;
130
+ height: auto;
131
+ border: round $accent;
132
+ background: $surface;
133
+ padding: 1 2;
134
+ }
135
+ Game2048Screen #game-header {
136
+ text-style: bold;
137
+ margin-bottom: 1;
138
+ content-align: center middle;
139
+ width: 100%;
140
+ }
141
+ Game2048Screen #game-grid {
142
+ grid-size: 4 4;
143
+ grid-columns: 6;
144
+ grid-rows: 3;
145
+ grid-gutter: 1;
146
+ width: 27;
147
+ height: 15;
148
+ }
149
+ Game2048Screen #game-footer {
150
+ margin-top: 1;
151
+ color: $text-muted;
152
+ content-align: center middle;
153
+ width: 100%;
154
+ }
155
+ Game2048Screen .tile {
156
+ content-align: center middle;
157
+ text-style: bold;
158
+ width: 100%;
159
+ height: 3;
160
+ background: $surface-lighten-1;
161
+ color: $text-muted;
162
+ }
163
+ Game2048Screen .tile-2 { background: #eee4da; color: #776e65; }
164
+ Game2048Screen .tile-4 { background: #ede0c8; color: #776e65; }
165
+ Game2048Screen .tile-8 { background: #f2b179; color: #f9f6f2; }
166
+ Game2048Screen .tile-16 { background: #f59563; color: #f9f6f2; }
167
+ Game2048Screen .tile-32 { background: #f67c5f; color: #f9f6f2; }
168
+ Game2048Screen .tile-64 { background: #f65e3b; color: #f9f6f2; }
169
+ Game2048Screen .tile-128 { background: #edcf72; color: #f9f6f2; }
170
+ Game2048Screen .tile-256 { background: #edcc61; color: #f9f6f2; }
171
+ Game2048Screen .tile-512 { background: #edc850; color: #f9f6f2; }
172
+ Game2048Screen .tile-1024 { background: #edc53f; color: #f9f6f2; }
173
+ Game2048Screen .tile-2048 { background: #edc22e; color: #f9f6f2; }
174
+ Game2048Screen .tile-super { background: #3c3a32; color: #f9f6f2; }