llmcode-cli 1.0.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 (212) hide show
  1. llm_code/__init__.py +2 -0
  2. llm_code/analysis/__init__.py +6 -0
  3. llm_code/analysis/cache.py +33 -0
  4. llm_code/analysis/engine.py +256 -0
  5. llm_code/analysis/go_rules.py +114 -0
  6. llm_code/analysis/js_rules.py +84 -0
  7. llm_code/analysis/python_rules.py +311 -0
  8. llm_code/analysis/rules.py +140 -0
  9. llm_code/analysis/rust_rules.py +108 -0
  10. llm_code/analysis/universal_rules.py +111 -0
  11. llm_code/api/__init__.py +0 -0
  12. llm_code/api/client.py +90 -0
  13. llm_code/api/errors.py +73 -0
  14. llm_code/api/openai_compat.py +390 -0
  15. llm_code/api/provider.py +35 -0
  16. llm_code/api/sse.py +52 -0
  17. llm_code/api/types.py +140 -0
  18. llm_code/cli/__init__.py +0 -0
  19. llm_code/cli/commands.py +70 -0
  20. llm_code/cli/image.py +122 -0
  21. llm_code/cli/render.py +214 -0
  22. llm_code/cli/status_line.py +79 -0
  23. llm_code/cli/streaming.py +92 -0
  24. llm_code/cli/tui_main.py +220 -0
  25. llm_code/computer_use/__init__.py +11 -0
  26. llm_code/computer_use/app_detect.py +49 -0
  27. llm_code/computer_use/app_tier.py +57 -0
  28. llm_code/computer_use/coordinator.py +99 -0
  29. llm_code/computer_use/input_control.py +71 -0
  30. llm_code/computer_use/screenshot.py +93 -0
  31. llm_code/cron/__init__.py +13 -0
  32. llm_code/cron/parser.py +145 -0
  33. llm_code/cron/scheduler.py +135 -0
  34. llm_code/cron/storage.py +126 -0
  35. llm_code/enterprise/__init__.py +1 -0
  36. llm_code/enterprise/audit.py +59 -0
  37. llm_code/enterprise/auth.py +26 -0
  38. llm_code/enterprise/oidc.py +95 -0
  39. llm_code/enterprise/rbac.py +65 -0
  40. llm_code/harness/__init__.py +5 -0
  41. llm_code/harness/config.py +33 -0
  42. llm_code/harness/engine.py +129 -0
  43. llm_code/harness/guides.py +41 -0
  44. llm_code/harness/sensors.py +68 -0
  45. llm_code/harness/templates.py +84 -0
  46. llm_code/hida/__init__.py +1 -0
  47. llm_code/hida/classifier.py +187 -0
  48. llm_code/hida/engine.py +49 -0
  49. llm_code/hida/profiles.py +95 -0
  50. llm_code/hida/types.py +28 -0
  51. llm_code/ide/__init__.py +1 -0
  52. llm_code/ide/bridge.py +80 -0
  53. llm_code/ide/detector.py +76 -0
  54. llm_code/ide/server.py +169 -0
  55. llm_code/logging.py +29 -0
  56. llm_code/lsp/__init__.py +0 -0
  57. llm_code/lsp/client.py +298 -0
  58. llm_code/lsp/detector.py +42 -0
  59. llm_code/lsp/manager.py +56 -0
  60. llm_code/lsp/tools.py +288 -0
  61. llm_code/marketplace/__init__.py +0 -0
  62. llm_code/marketplace/builtin_registry.py +102 -0
  63. llm_code/marketplace/installer.py +162 -0
  64. llm_code/marketplace/plugin.py +78 -0
  65. llm_code/marketplace/registry.py +360 -0
  66. llm_code/mcp/__init__.py +0 -0
  67. llm_code/mcp/bridge.py +87 -0
  68. llm_code/mcp/client.py +117 -0
  69. llm_code/mcp/health.py +120 -0
  70. llm_code/mcp/manager.py +214 -0
  71. llm_code/mcp/oauth.py +219 -0
  72. llm_code/mcp/transport.py +254 -0
  73. llm_code/mcp/types.py +53 -0
  74. llm_code/remote/__init__.py +0 -0
  75. llm_code/remote/client.py +136 -0
  76. llm_code/remote/protocol.py +22 -0
  77. llm_code/remote/server.py +275 -0
  78. llm_code/remote/ssh_proxy.py +56 -0
  79. llm_code/runtime/__init__.py +0 -0
  80. llm_code/runtime/auto_commit.py +56 -0
  81. llm_code/runtime/auto_diagnose.py +62 -0
  82. llm_code/runtime/checkpoint.py +70 -0
  83. llm_code/runtime/checkpoint_recovery.py +142 -0
  84. llm_code/runtime/compaction.py +35 -0
  85. llm_code/runtime/compressor.py +415 -0
  86. llm_code/runtime/config.py +533 -0
  87. llm_code/runtime/context.py +49 -0
  88. llm_code/runtime/conversation.py +921 -0
  89. llm_code/runtime/cost_tracker.py +126 -0
  90. llm_code/runtime/dream.py +127 -0
  91. llm_code/runtime/file_protection.py +150 -0
  92. llm_code/runtime/hardware.py +85 -0
  93. llm_code/runtime/hooks.py +223 -0
  94. llm_code/runtime/indexer.py +230 -0
  95. llm_code/runtime/knowledge_compiler.py +232 -0
  96. llm_code/runtime/memory.py +132 -0
  97. llm_code/runtime/memory_layers.py +467 -0
  98. llm_code/runtime/memory_lint.py +252 -0
  99. llm_code/runtime/model_aliases.py +37 -0
  100. llm_code/runtime/ollama.py +93 -0
  101. llm_code/runtime/overlay.py +124 -0
  102. llm_code/runtime/permissions.py +200 -0
  103. llm_code/runtime/plan.py +45 -0
  104. llm_code/runtime/prompt.py +238 -0
  105. llm_code/runtime/repo_map.py +174 -0
  106. llm_code/runtime/sandbox.py +116 -0
  107. llm_code/runtime/session.py +268 -0
  108. llm_code/runtime/skill_resolver.py +61 -0
  109. llm_code/runtime/skills.py +133 -0
  110. llm_code/runtime/speculative.py +75 -0
  111. llm_code/runtime/streaming_executor.py +216 -0
  112. llm_code/runtime/telemetry.py +196 -0
  113. llm_code/runtime/token_budget.py +26 -0
  114. llm_code/runtime/vcr.py +142 -0
  115. llm_code/runtime/vision.py +102 -0
  116. llm_code/swarm/__init__.py +1 -0
  117. llm_code/swarm/backend_subprocess.py +108 -0
  118. llm_code/swarm/backend_tmux.py +103 -0
  119. llm_code/swarm/backend_worktree.py +306 -0
  120. llm_code/swarm/checkpoint.py +74 -0
  121. llm_code/swarm/coordinator.py +236 -0
  122. llm_code/swarm/mailbox.py +88 -0
  123. llm_code/swarm/manager.py +202 -0
  124. llm_code/swarm/memory_sync.py +80 -0
  125. llm_code/swarm/recovery.py +21 -0
  126. llm_code/swarm/team.py +67 -0
  127. llm_code/swarm/types.py +31 -0
  128. llm_code/task/__init__.py +16 -0
  129. llm_code/task/diagnostics.py +93 -0
  130. llm_code/task/manager.py +162 -0
  131. llm_code/task/types.py +112 -0
  132. llm_code/task/verifier.py +104 -0
  133. llm_code/tools/__init__.py +0 -0
  134. llm_code/tools/agent.py +145 -0
  135. llm_code/tools/agent_roles.py +82 -0
  136. llm_code/tools/base.py +94 -0
  137. llm_code/tools/bash.py +565 -0
  138. llm_code/tools/computer_use_tools.py +278 -0
  139. llm_code/tools/coordinator_tool.py +75 -0
  140. llm_code/tools/cron_create.py +90 -0
  141. llm_code/tools/cron_delete.py +49 -0
  142. llm_code/tools/cron_list.py +51 -0
  143. llm_code/tools/deferred.py +92 -0
  144. llm_code/tools/dump.py +116 -0
  145. llm_code/tools/edit_file.py +282 -0
  146. llm_code/tools/git_tools.py +531 -0
  147. llm_code/tools/glob_search.py +112 -0
  148. llm_code/tools/grep_search.py +144 -0
  149. llm_code/tools/ide_diagnostics.py +59 -0
  150. llm_code/tools/ide_open.py +58 -0
  151. llm_code/tools/ide_selection.py +52 -0
  152. llm_code/tools/memory_tools.py +138 -0
  153. llm_code/tools/multi_edit.py +143 -0
  154. llm_code/tools/notebook_edit.py +107 -0
  155. llm_code/tools/notebook_read.py +81 -0
  156. llm_code/tools/parsing.py +63 -0
  157. llm_code/tools/read_file.py +154 -0
  158. llm_code/tools/registry.py +58 -0
  159. llm_code/tools/search_backends/__init__.py +56 -0
  160. llm_code/tools/search_backends/brave.py +56 -0
  161. llm_code/tools/search_backends/duckduckgo.py +129 -0
  162. llm_code/tools/search_backends/searxng.py +71 -0
  163. llm_code/tools/search_backends/tavily.py +73 -0
  164. llm_code/tools/swarm_create.py +109 -0
  165. llm_code/tools/swarm_delete.py +95 -0
  166. llm_code/tools/swarm_list.py +44 -0
  167. llm_code/tools/swarm_message.py +109 -0
  168. llm_code/tools/task_close.py +79 -0
  169. llm_code/tools/task_plan.py +79 -0
  170. llm_code/tools/task_verify.py +90 -0
  171. llm_code/tools/tool_search.py +65 -0
  172. llm_code/tools/web_common.py +258 -0
  173. llm_code/tools/web_fetch.py +223 -0
  174. llm_code/tools/web_search.py +280 -0
  175. llm_code/tools/write_file.py +118 -0
  176. llm_code/tui/__init__.py +1 -0
  177. llm_code/tui/app.py +2432 -0
  178. llm_code/tui/chat_view.py +82 -0
  179. llm_code/tui/chat_widgets.py +309 -0
  180. llm_code/tui/header_bar.py +46 -0
  181. llm_code/tui/input_bar.py +349 -0
  182. llm_code/tui/keybindings.py +142 -0
  183. llm_code/tui/marketplace.py +210 -0
  184. llm_code/tui/status_bar.py +72 -0
  185. llm_code/tui/theme.py +96 -0
  186. llm_code/utils/__init__.py +0 -0
  187. llm_code/utils/diff.py +111 -0
  188. llm_code/utils/errors.py +70 -0
  189. llm_code/utils/hyperlink.py +73 -0
  190. llm_code/utils/notebook.py +179 -0
  191. llm_code/utils/search.py +69 -0
  192. llm_code/utils/text_normalize.py +28 -0
  193. llm_code/utils/version_check.py +62 -0
  194. llm_code/vim/__init__.py +4 -0
  195. llm_code/vim/engine.py +51 -0
  196. llm_code/vim/motions.py +172 -0
  197. llm_code/vim/operators.py +183 -0
  198. llm_code/vim/text_objects.py +139 -0
  199. llm_code/vim/transitions.py +279 -0
  200. llm_code/vim/types.py +68 -0
  201. llm_code/voice/__init__.py +1 -0
  202. llm_code/voice/languages.py +43 -0
  203. llm_code/voice/recorder.py +136 -0
  204. llm_code/voice/stt.py +36 -0
  205. llm_code/voice/stt_anthropic.py +66 -0
  206. llm_code/voice/stt_google.py +32 -0
  207. llm_code/voice/stt_whisper.py +52 -0
  208. llmcode_cli-1.0.0.dist-info/METADATA +524 -0
  209. llmcode_cli-1.0.0.dist-info/RECORD +212 -0
  210. llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
  211. llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
  212. llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,144 @@
1
+ """GrepSearchTool — regex search across files."""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import re
6
+ from typing import Callable
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from llm_code.tools.base import PermissionLevel, Tool, ToolProgress, ToolResult
11
+
12
+ _MAX_MATCHES = 100
13
+ _MAX_FILES_SCANNED = 500
14
+ _PROGRESS_INTERVAL = 100 # emit a progress event every N files scanned
15
+
16
+
17
+ class GrepSearchInput(BaseModel):
18
+ pattern: str
19
+ path: str = "."
20
+ glob: str = "**/*"
21
+ context: int = 0
22
+
23
+
24
+ class GrepSearchTool(Tool):
25
+ @property
26
+ def name(self) -> str:
27
+ return "grep_search"
28
+
29
+ @property
30
+ def description(self) -> str:
31
+ return (
32
+ "Search for a regex pattern across files in a directory. "
33
+ "Returns up to 100 matches across up to 500 files."
34
+ )
35
+
36
+ @property
37
+ def input_schema(self) -> dict:
38
+ return {
39
+ "type": "object",
40
+ "properties": {
41
+ "pattern": {"type": "string", "description": "Regex pattern to search for"},
42
+ "path": {
43
+ "type": "string",
44
+ "description": "Directory to search in (default: current dir)",
45
+ },
46
+ "glob": {
47
+ "type": "string",
48
+ "description": "Glob filter for filenames (e.g. *.py)",
49
+ },
50
+ "context": {
51
+ "type": "integer",
52
+ "description": "Lines of context to include before and after each match",
53
+ "default": 0,
54
+ },
55
+ },
56
+ "required": ["pattern"],
57
+ }
58
+
59
+ @property
60
+ def required_permission(self) -> PermissionLevel:
61
+ return PermissionLevel.READ_ONLY
62
+
63
+ @property
64
+ def input_model(self) -> type[GrepSearchInput]:
65
+ return GrepSearchInput
66
+
67
+ def is_read_only(self, args: dict) -> bool:
68
+ return True
69
+
70
+ def is_concurrency_safe(self, args: dict) -> bool:
71
+ return True
72
+
73
+ def execute(self, args: dict) -> ToolResult:
74
+ return self._search(args, on_progress=None)
75
+
76
+ def execute_with_progress(
77
+ self,
78
+ args: dict,
79
+ on_progress: Callable[[ToolProgress], None],
80
+ ) -> ToolResult:
81
+ return self._search(args, on_progress=on_progress)
82
+
83
+ def _search(
84
+ self,
85
+ args: dict,
86
+ on_progress: Callable[[ToolProgress], None] | None,
87
+ ) -> ToolResult:
88
+ pattern_str: str = args["pattern"]
89
+ search_path = pathlib.Path(args.get("path", "."))
90
+ glob_filter: str = args.get("glob", "**/*")
91
+ context_lines: int = int(args.get("context", 0))
92
+
93
+ try:
94
+ regex = re.compile(pattern_str)
95
+ except re.error as exc:
96
+ return ToolResult(output=f"Invalid regex: {exc}", is_error=True)
97
+
98
+ # Collect candidate files
99
+ try:
100
+ candidates = [p for p in search_path.glob(glob_filter) if p.is_file()]
101
+ except Exception as exc:
102
+ return ToolResult(output=f"Glob error: {exc}", is_error=True)
103
+
104
+ candidates = candidates[:_MAX_FILES_SCANNED]
105
+ total = len(candidates)
106
+
107
+ results: list[str] = []
108
+ match_count = 0
109
+
110
+ for file_idx, file_path in enumerate(candidates, start=1):
111
+ if match_count >= _MAX_MATCHES:
112
+ break
113
+
114
+ # Emit progress every PROGRESS_INTERVAL files
115
+ if on_progress is not None and file_idx % _PROGRESS_INTERVAL == 0:
116
+ percent = round(file_idx / total * 100.0, 1) if total else 100.0
117
+ on_progress(
118
+ ToolProgress(
119
+ tool_name=self.name,
120
+ message=f"Scanned {file_idx}/{total} files",
121
+ percent=percent,
122
+ )
123
+ )
124
+
125
+ try:
126
+ lines = file_path.read_text(errors="replace").splitlines()
127
+ except Exception:
128
+ continue
129
+
130
+ for i, line in enumerate(lines):
131
+ if match_count >= _MAX_MATCHES:
132
+ break
133
+ if regex.search(line):
134
+ # Gather context
135
+ start = max(0, i - context_lines)
136
+ end = min(len(lines), i + context_lines + 1)
137
+ block = [f"{file_path}:{start + j + 1}: {lines[start + j]}" for j in range(end - start)]
138
+ results.append("\n".join(block))
139
+ match_count += 1
140
+
141
+ if not results:
142
+ return ToolResult(output=f"No matches found for: {pattern_str}")
143
+
144
+ return ToolResult(output="\n---\n".join(results))
@@ -0,0 +1,59 @@
1
+ """IDEDiagnosticsTool — get diagnostics from the connected IDE."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+
6
+ from llm_code.ide.bridge import IDEBridge
7
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
8
+
9
+
10
+ class IDEDiagnosticsTool(Tool):
11
+ def __init__(self, bridge: IDEBridge) -> None:
12
+ self._bridge = bridge
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "ide_diagnostics"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return "Get diagnostics (errors, warnings) for a file from the connected IDE."
21
+
22
+ @property
23
+ def input_schema(self) -> dict:
24
+ return {
25
+ "type": "object",
26
+ "properties": {
27
+ "path": {"type": "string", "description": "Absolute path to the file"},
28
+ },
29
+ "required": ["path"],
30
+ }
31
+
32
+ @property
33
+ def required_permission(self) -> PermissionLevel:
34
+ return PermissionLevel.READ_ONLY
35
+
36
+ def is_read_only(self, args: dict) -> bool:
37
+ return True
38
+
39
+ def is_concurrency_safe(self, args: dict) -> bool:
40
+ return True
41
+
42
+ def execute(self, args: dict) -> ToolResult:
43
+ path = args["path"]
44
+ loop = asyncio.get_event_loop()
45
+ diags = loop.run_until_complete(self._bridge.get_diagnostics(path))
46
+
47
+ if not diags:
48
+ return ToolResult(output=f"No diagnostics for {path}.")
49
+
50
+ lines: list[str] = [f"Diagnostics for {path} ({len(diags)} issues):"]
51
+ for d in diags:
52
+ line_num = d.get("line", "?")
53
+ severity = d.get("severity", "info")
54
+ message = d.get("message", "")
55
+ source = d.get("source", "")
56
+ src_str = f" [{source}]" if source else ""
57
+ lines.append(f" L{line_num} {severity}: {message}{src_str}")
58
+
59
+ return ToolResult(output="\n".join(lines))
@@ -0,0 +1,58 @@
1
+ """IDEOpenTool — ask the connected IDE to open a file."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+
6
+ from llm_code.ide.bridge import IDEBridge
7
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
8
+
9
+
10
+ class IDEOpenTool(Tool):
11
+ def __init__(self, bridge: IDEBridge) -> None:
12
+ self._bridge = bridge
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "ide_open"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return "Open a file in the connected IDE at an optional line number."
21
+
22
+ @property
23
+ def input_schema(self) -> dict:
24
+ return {
25
+ "type": "object",
26
+ "properties": {
27
+ "path": {"type": "string", "description": "Absolute path to the file"},
28
+ "line": {
29
+ "type": "integer",
30
+ "description": "Line number to jump to (optional)",
31
+ },
32
+ },
33
+ "required": ["path"],
34
+ }
35
+
36
+ @property
37
+ def required_permission(self) -> PermissionLevel:
38
+ return PermissionLevel.READ_ONLY
39
+
40
+ def is_read_only(self, args: dict) -> bool:
41
+ return True
42
+
43
+ def is_concurrency_safe(self, args: dict) -> bool:
44
+ return True
45
+
46
+ def execute(self, args: dict) -> ToolResult:
47
+ if not self._bridge.is_connected:
48
+ return ToolResult(output="No IDE connected. Use /ide connect first.", is_error=True)
49
+
50
+ path = args["path"]
51
+ line = args.get("line")
52
+ loop = asyncio.get_event_loop()
53
+ ok = loop.run_until_complete(self._bridge.open_file(path, line=line))
54
+
55
+ if ok:
56
+ line_str = f" at line {line}" if line else ""
57
+ return ToolResult(output=f"Opened {path}{line_str} in IDE.")
58
+ return ToolResult(output=f"Failed to open {path} in IDE.", is_error=True)
@@ -0,0 +1,52 @@
1
+ """IDESelectionTool — get the current editor selection from the connected IDE."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+
6
+ from llm_code.ide.bridge import IDEBridge
7
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
8
+
9
+
10
+ class IDESelectionTool(Tool):
11
+ def __init__(self, bridge: IDEBridge) -> None:
12
+ self._bridge = bridge
13
+
14
+ @property
15
+ def name(self) -> str:
16
+ return "ide_selection"
17
+
18
+ @property
19
+ def description(self) -> str:
20
+ return "Get the currently selected text in the connected IDE's editor."
21
+
22
+ @property
23
+ def input_schema(self) -> dict:
24
+ return {
25
+ "type": "object",
26
+ "properties": {},
27
+ }
28
+
29
+ @property
30
+ def required_permission(self) -> PermissionLevel:
31
+ return PermissionLevel.READ_ONLY
32
+
33
+ def is_read_only(self, args: dict) -> bool:
34
+ return True
35
+
36
+ def is_concurrency_safe(self, args: dict) -> bool:
37
+ return True
38
+
39
+ def execute(self, args: dict) -> ToolResult:
40
+ loop = asyncio.get_event_loop()
41
+ sel = loop.run_until_complete(self._bridge.get_selection())
42
+
43
+ if sel is None:
44
+ return ToolResult(output="No selection — no IDE connected or nothing selected.")
45
+
46
+ path = sel.get("path", "unknown")
47
+ start = sel.get("start_line", "?")
48
+ end = sel.get("end_line", "?")
49
+ text = sel.get("text", "")
50
+
51
+ header = f"Selection in {path} (lines {start}-{end}):"
52
+ return ToolResult(output=f"{header}\n{text}")
@@ -0,0 +1,138 @@
1
+ """Memory tools: store, recall, and list cross-session memory entries."""
2
+ from __future__ import annotations
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from llm_code.runtime.memory import MemoryStore
7
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
8
+
9
+
10
+ class MemoryStoreInput(BaseModel):
11
+ key: str
12
+ value: str
13
+
14
+
15
+ class MemoryRecallInput(BaseModel):
16
+ key: str
17
+
18
+
19
+ class MemoryStoreTool(Tool):
20
+ """Store a value in persistent memory under a given key."""
21
+
22
+ def __init__(self, memory: MemoryStore) -> None:
23
+ self._memory = memory
24
+
25
+ @property
26
+ def name(self) -> str:
27
+ return "memory_store"
28
+
29
+ @property
30
+ def description(self) -> str:
31
+ return "Store a value in persistent cross-session memory under a given key."
32
+
33
+ @property
34
+ def input_schema(self) -> dict:
35
+ return {
36
+ "type": "object",
37
+ "properties": {
38
+ "key": {"type": "string", "description": "The memory key"},
39
+ "value": {"type": "string", "description": "The value to store"},
40
+ },
41
+ "required": ["key", "value"],
42
+ }
43
+
44
+ @property
45
+ def required_permission(self) -> PermissionLevel:
46
+ return PermissionLevel.WORKSPACE_WRITE
47
+
48
+ @property
49
+ def input_model(self) -> type[MemoryStoreInput]:
50
+ return MemoryStoreInput
51
+
52
+ def execute(self, args: dict) -> ToolResult:
53
+ self._memory.store(args["key"], args["value"])
54
+ return ToolResult(output=f"Stored: {args['key']}")
55
+
56
+
57
+ class MemoryRecallTool(Tool):
58
+ """Recall a value from persistent memory by key."""
59
+
60
+ def __init__(self, memory: MemoryStore) -> None:
61
+ self._memory = memory
62
+
63
+ @property
64
+ def name(self) -> str:
65
+ return "memory_recall"
66
+
67
+ @property
68
+ def description(self) -> str:
69
+ return "Recall a value from persistent cross-session memory by key."
70
+
71
+ @property
72
+ def input_schema(self) -> dict:
73
+ return {
74
+ "type": "object",
75
+ "properties": {
76
+ "key": {"type": "string", "description": "The memory key to recall"},
77
+ },
78
+ "required": ["key"],
79
+ }
80
+
81
+ @property
82
+ def required_permission(self) -> PermissionLevel:
83
+ return PermissionLevel.READ_ONLY
84
+
85
+ @property
86
+ def input_model(self) -> type[MemoryRecallInput]:
87
+ return MemoryRecallInput
88
+
89
+ def is_read_only(self, args: dict) -> bool:
90
+ return True
91
+
92
+ def is_concurrency_safe(self, args: dict) -> bool:
93
+ return True
94
+
95
+ def execute(self, args: dict) -> ToolResult:
96
+ value = self._memory.recall(args["key"])
97
+ if value is None:
98
+ return ToolResult(output=f"No memory found for key: {args['key']}", is_error=True)
99
+ return ToolResult(output=value)
100
+
101
+
102
+ class MemoryListTool(Tool):
103
+ """List all keys and values stored in persistent memory."""
104
+
105
+ def __init__(self, memory: MemoryStore) -> None:
106
+ self._memory = memory
107
+
108
+ @property
109
+ def name(self) -> str:
110
+ return "memory_list"
111
+
112
+ @property
113
+ def description(self) -> str:
114
+ return "List all keys and values stored in persistent cross-session memory."
115
+
116
+ @property
117
+ def input_schema(self) -> dict:
118
+ return {
119
+ "type": "object",
120
+ "properties": {},
121
+ }
122
+
123
+ @property
124
+ def required_permission(self) -> PermissionLevel:
125
+ return PermissionLevel.READ_ONLY
126
+
127
+ def is_read_only(self, args: dict) -> bool:
128
+ return True
129
+
130
+ def execute(self, args: dict) -> ToolResult:
131
+ entries = self._memory.get_all()
132
+ if not entries:
133
+ return ToolResult(output="No memories stored.")
134
+ lines = [
135
+ f"- {k}: {v.value[:50]}..." if len(v.value) > 50 else f"- {k}: {v.value}"
136
+ for k, v in entries.items()
137
+ ]
138
+ return ToolResult(output="\n".join(lines))
@@ -0,0 +1,143 @@
1
+ """MultiEditTool — atomic multi-file search-and-replace."""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ from typing import TYPE_CHECKING
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from llm_code.runtime.file_protection import check_write
10
+ from llm_code.tools.base import PermissionLevel, Tool, ToolResult
11
+ from llm_code.tools.edit_file import _apply_edit
12
+
13
+ if TYPE_CHECKING:
14
+ from llm_code.runtime.overlay import OverlayFS
15
+
16
+ _MAX_EDITS = 20
17
+
18
+
19
+ class SingleEdit(BaseModel):
20
+ path: str
21
+ old: str
22
+ new: str
23
+ replace_all: bool = False
24
+
25
+
26
+ class MultiEditInput(BaseModel):
27
+ edits: list[SingleEdit]
28
+
29
+
30
+ class MultiEditTool(Tool):
31
+ @property
32
+ def name(self) -> str:
33
+ return "multi_edit"
34
+
35
+ @property
36
+ def description(self) -> str:
37
+ return "Atomic multi-file search-and-replace. All edits succeed or none are applied."
38
+
39
+ @property
40
+ def input_schema(self) -> dict:
41
+ return {
42
+ "type": "object",
43
+ "properties": {
44
+ "edits": {
45
+ "type": "array",
46
+ "items": {
47
+ "type": "object",
48
+ "properties": {
49
+ "path": {"type": "string", "description": "Absolute path to file"},
50
+ "old": {"type": "string", "description": "Text to search for"},
51
+ "new": {"type": "string", "description": "Replacement text"},
52
+ "replace_all": {"type": "boolean", "default": False},
53
+ },
54
+ "required": ["path", "old", "new"],
55
+ },
56
+ "minItems": 1,
57
+ "maxItems": _MAX_EDITS,
58
+ }
59
+ },
60
+ "required": ["edits"],
61
+ }
62
+
63
+ @property
64
+ def required_permission(self) -> PermissionLevel:
65
+ return PermissionLevel.WORKSPACE_WRITE
66
+
67
+ @property
68
+ def input_model(self) -> type[MultiEditInput]:
69
+ return MultiEditInput
70
+
71
+ def execute(self, args: dict, overlay: "OverlayFS | None" = None) -> ToolResult:
72
+ edits_raw = args.get("edits", [])
73
+
74
+ if len(edits_raw) > _MAX_EDITS:
75
+ return ToolResult(
76
+ output=f"Too many edits ({len(edits_raw)}). Maximum is {_MAX_EDITS}.",
77
+ is_error=True,
78
+ )
79
+
80
+ edits = [SingleEdit(**e) if isinstance(e, dict) else e for e in edits_raw]
81
+
82
+ # Phase 1: Pre-validate (existence + write permission)
83
+ errors: list[str] = []
84
+ for i, edit in enumerate(edits):
85
+ path = pathlib.Path(edit.path)
86
+ if overlay is None:
87
+ if not path.exists():
88
+ errors.append(f"Edit {i + 1}: File not found: {path}")
89
+ continue
90
+ else:
91
+ try:
92
+ overlay.read(path)
93
+ except FileNotFoundError:
94
+ errors.append(f"Edit {i + 1}: File not found: {path}")
95
+ continue
96
+ protection = check_write(str(path))
97
+ if not protection.allowed:
98
+ errors.append(f"Edit {i + 1}: {protection.reason}")
99
+ if errors:
100
+ return ToolResult(output="Validation failed:\n" + "\n".join(errors), is_error=True)
101
+
102
+ # Phase 2: Snapshot original contents
103
+ snapshots: dict[str, str] = {}
104
+ for edit in edits:
105
+ p = str(edit.path)
106
+ if p not in snapshots:
107
+ path = pathlib.Path(p)
108
+ if overlay is not None:
109
+ snapshots[p] = overlay.read(path)
110
+ else:
111
+ snapshots[p] = path.read_text(encoding="utf-8")
112
+
113
+ # Phase 3: Apply all edits in memory
114
+ applied: list[str] = []
115
+ current_contents: dict[str, str] = dict(snapshots)
116
+ for i, edit in enumerate(edits):
117
+ p = str(edit.path)
118
+ result = _apply_edit(current_contents[p], edit.old, edit.new, edit.replace_all)
119
+ if not result.success:
120
+ # Rollback: restore snapshots to real FS (overlay needs no rollback
121
+ # — caller discards the overlay on failure)
122
+ if overlay is None:
123
+ for sp, sc in snapshots.items():
124
+ pathlib.Path(sp).write_text(sc, encoding="utf-8")
125
+ return ToolResult(
126
+ output=f"Edit {i + 1} failed ({edit.path}): {result.error}. All edits rolled back.",
127
+ is_error=True,
128
+ )
129
+ current_contents[p] = result.new_content
130
+ applied.append(f"Edit {i + 1}: {edit.path} ({result.replaced} replacement(s))")
131
+
132
+ # Phase 4: Write all files
133
+ for p, content in current_contents.items():
134
+ path = pathlib.Path(p)
135
+ if overlay is not None:
136
+ overlay.write(path, content)
137
+ else:
138
+ path.write_text(content, encoding="utf-8")
139
+
140
+ return ToolResult(
141
+ output=f"Applied {len(edits)} edits:\n" + "\n".join(applied),
142
+ metadata={"edits_applied": len(edits)},
143
+ )