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.
- llm_code/__init__.py +2 -0
- llm_code/analysis/__init__.py +6 -0
- llm_code/analysis/cache.py +33 -0
- llm_code/analysis/engine.py +256 -0
- llm_code/analysis/go_rules.py +114 -0
- llm_code/analysis/js_rules.py +84 -0
- llm_code/analysis/python_rules.py +311 -0
- llm_code/analysis/rules.py +140 -0
- llm_code/analysis/rust_rules.py +108 -0
- llm_code/analysis/universal_rules.py +111 -0
- llm_code/api/__init__.py +0 -0
- llm_code/api/client.py +90 -0
- llm_code/api/errors.py +73 -0
- llm_code/api/openai_compat.py +390 -0
- llm_code/api/provider.py +35 -0
- llm_code/api/sse.py +52 -0
- llm_code/api/types.py +140 -0
- llm_code/cli/__init__.py +0 -0
- llm_code/cli/commands.py +70 -0
- llm_code/cli/image.py +122 -0
- llm_code/cli/render.py +214 -0
- llm_code/cli/status_line.py +79 -0
- llm_code/cli/streaming.py +92 -0
- llm_code/cli/tui_main.py +220 -0
- llm_code/computer_use/__init__.py +11 -0
- llm_code/computer_use/app_detect.py +49 -0
- llm_code/computer_use/app_tier.py +57 -0
- llm_code/computer_use/coordinator.py +99 -0
- llm_code/computer_use/input_control.py +71 -0
- llm_code/computer_use/screenshot.py +93 -0
- llm_code/cron/__init__.py +13 -0
- llm_code/cron/parser.py +145 -0
- llm_code/cron/scheduler.py +135 -0
- llm_code/cron/storage.py +126 -0
- llm_code/enterprise/__init__.py +1 -0
- llm_code/enterprise/audit.py +59 -0
- llm_code/enterprise/auth.py +26 -0
- llm_code/enterprise/oidc.py +95 -0
- llm_code/enterprise/rbac.py +65 -0
- llm_code/harness/__init__.py +5 -0
- llm_code/harness/config.py +33 -0
- llm_code/harness/engine.py +129 -0
- llm_code/harness/guides.py +41 -0
- llm_code/harness/sensors.py +68 -0
- llm_code/harness/templates.py +84 -0
- llm_code/hida/__init__.py +1 -0
- llm_code/hida/classifier.py +187 -0
- llm_code/hida/engine.py +49 -0
- llm_code/hida/profiles.py +95 -0
- llm_code/hida/types.py +28 -0
- llm_code/ide/__init__.py +1 -0
- llm_code/ide/bridge.py +80 -0
- llm_code/ide/detector.py +76 -0
- llm_code/ide/server.py +169 -0
- llm_code/logging.py +29 -0
- llm_code/lsp/__init__.py +0 -0
- llm_code/lsp/client.py +298 -0
- llm_code/lsp/detector.py +42 -0
- llm_code/lsp/manager.py +56 -0
- llm_code/lsp/tools.py +288 -0
- llm_code/marketplace/__init__.py +0 -0
- llm_code/marketplace/builtin_registry.py +102 -0
- llm_code/marketplace/installer.py +162 -0
- llm_code/marketplace/plugin.py +78 -0
- llm_code/marketplace/registry.py +360 -0
- llm_code/mcp/__init__.py +0 -0
- llm_code/mcp/bridge.py +87 -0
- llm_code/mcp/client.py +117 -0
- llm_code/mcp/health.py +120 -0
- llm_code/mcp/manager.py +214 -0
- llm_code/mcp/oauth.py +219 -0
- llm_code/mcp/transport.py +254 -0
- llm_code/mcp/types.py +53 -0
- llm_code/remote/__init__.py +0 -0
- llm_code/remote/client.py +136 -0
- llm_code/remote/protocol.py +22 -0
- llm_code/remote/server.py +275 -0
- llm_code/remote/ssh_proxy.py +56 -0
- llm_code/runtime/__init__.py +0 -0
- llm_code/runtime/auto_commit.py +56 -0
- llm_code/runtime/auto_diagnose.py +62 -0
- llm_code/runtime/checkpoint.py +70 -0
- llm_code/runtime/checkpoint_recovery.py +142 -0
- llm_code/runtime/compaction.py +35 -0
- llm_code/runtime/compressor.py +415 -0
- llm_code/runtime/config.py +533 -0
- llm_code/runtime/context.py +49 -0
- llm_code/runtime/conversation.py +921 -0
- llm_code/runtime/cost_tracker.py +126 -0
- llm_code/runtime/dream.py +127 -0
- llm_code/runtime/file_protection.py +150 -0
- llm_code/runtime/hardware.py +85 -0
- llm_code/runtime/hooks.py +223 -0
- llm_code/runtime/indexer.py +230 -0
- llm_code/runtime/knowledge_compiler.py +232 -0
- llm_code/runtime/memory.py +132 -0
- llm_code/runtime/memory_layers.py +467 -0
- llm_code/runtime/memory_lint.py +252 -0
- llm_code/runtime/model_aliases.py +37 -0
- llm_code/runtime/ollama.py +93 -0
- llm_code/runtime/overlay.py +124 -0
- llm_code/runtime/permissions.py +200 -0
- llm_code/runtime/plan.py +45 -0
- llm_code/runtime/prompt.py +238 -0
- llm_code/runtime/repo_map.py +174 -0
- llm_code/runtime/sandbox.py +116 -0
- llm_code/runtime/session.py +268 -0
- llm_code/runtime/skill_resolver.py +61 -0
- llm_code/runtime/skills.py +133 -0
- llm_code/runtime/speculative.py +75 -0
- llm_code/runtime/streaming_executor.py +216 -0
- llm_code/runtime/telemetry.py +196 -0
- llm_code/runtime/token_budget.py +26 -0
- llm_code/runtime/vcr.py +142 -0
- llm_code/runtime/vision.py +102 -0
- llm_code/swarm/__init__.py +1 -0
- llm_code/swarm/backend_subprocess.py +108 -0
- llm_code/swarm/backend_tmux.py +103 -0
- llm_code/swarm/backend_worktree.py +306 -0
- llm_code/swarm/checkpoint.py +74 -0
- llm_code/swarm/coordinator.py +236 -0
- llm_code/swarm/mailbox.py +88 -0
- llm_code/swarm/manager.py +202 -0
- llm_code/swarm/memory_sync.py +80 -0
- llm_code/swarm/recovery.py +21 -0
- llm_code/swarm/team.py +67 -0
- llm_code/swarm/types.py +31 -0
- llm_code/task/__init__.py +16 -0
- llm_code/task/diagnostics.py +93 -0
- llm_code/task/manager.py +162 -0
- llm_code/task/types.py +112 -0
- llm_code/task/verifier.py +104 -0
- llm_code/tools/__init__.py +0 -0
- llm_code/tools/agent.py +145 -0
- llm_code/tools/agent_roles.py +82 -0
- llm_code/tools/base.py +94 -0
- llm_code/tools/bash.py +565 -0
- llm_code/tools/computer_use_tools.py +278 -0
- llm_code/tools/coordinator_tool.py +75 -0
- llm_code/tools/cron_create.py +90 -0
- llm_code/tools/cron_delete.py +49 -0
- llm_code/tools/cron_list.py +51 -0
- llm_code/tools/deferred.py +92 -0
- llm_code/tools/dump.py +116 -0
- llm_code/tools/edit_file.py +282 -0
- llm_code/tools/git_tools.py +531 -0
- llm_code/tools/glob_search.py +112 -0
- llm_code/tools/grep_search.py +144 -0
- llm_code/tools/ide_diagnostics.py +59 -0
- llm_code/tools/ide_open.py +58 -0
- llm_code/tools/ide_selection.py +52 -0
- llm_code/tools/memory_tools.py +138 -0
- llm_code/tools/multi_edit.py +143 -0
- llm_code/tools/notebook_edit.py +107 -0
- llm_code/tools/notebook_read.py +81 -0
- llm_code/tools/parsing.py +63 -0
- llm_code/tools/read_file.py +154 -0
- llm_code/tools/registry.py +58 -0
- llm_code/tools/search_backends/__init__.py +56 -0
- llm_code/tools/search_backends/brave.py +56 -0
- llm_code/tools/search_backends/duckduckgo.py +129 -0
- llm_code/tools/search_backends/searxng.py +71 -0
- llm_code/tools/search_backends/tavily.py +73 -0
- llm_code/tools/swarm_create.py +109 -0
- llm_code/tools/swarm_delete.py +95 -0
- llm_code/tools/swarm_list.py +44 -0
- llm_code/tools/swarm_message.py +109 -0
- llm_code/tools/task_close.py +79 -0
- llm_code/tools/task_plan.py +79 -0
- llm_code/tools/task_verify.py +90 -0
- llm_code/tools/tool_search.py +65 -0
- llm_code/tools/web_common.py +258 -0
- llm_code/tools/web_fetch.py +223 -0
- llm_code/tools/web_search.py +280 -0
- llm_code/tools/write_file.py +118 -0
- llm_code/tui/__init__.py +1 -0
- llm_code/tui/app.py +2432 -0
- llm_code/tui/chat_view.py +82 -0
- llm_code/tui/chat_widgets.py +309 -0
- llm_code/tui/header_bar.py +46 -0
- llm_code/tui/input_bar.py +349 -0
- llm_code/tui/keybindings.py +142 -0
- llm_code/tui/marketplace.py +210 -0
- llm_code/tui/status_bar.py +72 -0
- llm_code/tui/theme.py +96 -0
- llm_code/utils/__init__.py +0 -0
- llm_code/utils/diff.py +111 -0
- llm_code/utils/errors.py +70 -0
- llm_code/utils/hyperlink.py +73 -0
- llm_code/utils/notebook.py +179 -0
- llm_code/utils/search.py +69 -0
- llm_code/utils/text_normalize.py +28 -0
- llm_code/utils/version_check.py +62 -0
- llm_code/vim/__init__.py +4 -0
- llm_code/vim/engine.py +51 -0
- llm_code/vim/motions.py +172 -0
- llm_code/vim/operators.py +183 -0
- llm_code/vim/text_objects.py +139 -0
- llm_code/vim/transitions.py +279 -0
- llm_code/vim/types.py +68 -0
- llm_code/voice/__init__.py +1 -0
- llm_code/voice/languages.py +43 -0
- llm_code/voice/recorder.py +136 -0
- llm_code/voice/stt.py +36 -0
- llm_code/voice/stt_anthropic.py +66 -0
- llm_code/voice/stt_google.py +32 -0
- llm_code/voice/stt_whisper.py +52 -0
- llmcode_cli-1.0.0.dist-info/METADATA +524 -0
- llmcode_cli-1.0.0.dist-info/RECORD +212 -0
- llmcode_cli-1.0.0.dist-info/WHEEL +4 -0
- llmcode_cli-1.0.0.dist-info/entry_points.txt +2 -0
- llmcode_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Permission policy for tool execution authorization."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import logging
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
from llm_code.tools.base import PermissionLevel
|
|
9
|
+
|
|
10
|
+
_log = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PermissionMode(Enum):
|
|
14
|
+
READ_ONLY = "read_only"
|
|
15
|
+
WORKSPACE_WRITE = "workspace_write"
|
|
16
|
+
FULL_ACCESS = "full_access"
|
|
17
|
+
PROMPT = "prompt"
|
|
18
|
+
AUTO_ACCEPT = "auto_accept"
|
|
19
|
+
PLAN = "plan"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PermissionOutcome(Enum):
|
|
23
|
+
ALLOW = "allow"
|
|
24
|
+
DENY = "deny"
|
|
25
|
+
NEED_PROMPT = "need_prompt"
|
|
26
|
+
NEED_PLAN = "need_plan"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Numeric levels for comparison (higher = more permissive)
|
|
30
|
+
_LEVEL_RANK: dict[PermissionLevel, int] = {
|
|
31
|
+
PermissionLevel.READ_ONLY: 0,
|
|
32
|
+
PermissionLevel.WORKSPACE_WRITE: 1,
|
|
33
|
+
PermissionLevel.FULL_ACCESS: 2,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Maximum permission level each mode allows without prompting
|
|
37
|
+
_MODE_MAX_LEVEL: dict[PermissionMode, int] = {
|
|
38
|
+
PermissionMode.READ_ONLY: 0,
|
|
39
|
+
PermissionMode.WORKSPACE_WRITE: 1,
|
|
40
|
+
PermissionMode.FULL_ACCESS: 2,
|
|
41
|
+
PermissionMode.AUTO_ACCEPT: 2,
|
|
42
|
+
PermissionMode.PROMPT: -1, # PROMPT handled separately
|
|
43
|
+
PermissionMode.PLAN: 2, # PLAN handled separately; max level unused but set for safety
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect_shadowed_rules(
|
|
48
|
+
allow_tools: frozenset[str],
|
|
49
|
+
deny_tools: frozenset[str],
|
|
50
|
+
mode: PermissionMode,
|
|
51
|
+
) -> list[str]:
|
|
52
|
+
"""Return warning messages for conflicting or redundant permission rules.
|
|
53
|
+
|
|
54
|
+
Detects three categories of problems:
|
|
55
|
+
- Allow rules shadowed by deny rules (same tool in both lists).
|
|
56
|
+
- Allow rules that are unnecessary because the mode already allows them.
|
|
57
|
+
- Deny rules that are unnecessary because the mode already blocks them.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
allow_tools: Explicit allow list.
|
|
61
|
+
deny_tools: Explicit deny list.
|
|
62
|
+
mode: The active permission mode.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A list of human-readable warning strings (empty when no issues found).
|
|
66
|
+
"""
|
|
67
|
+
warnings: list[str] = []
|
|
68
|
+
|
|
69
|
+
# 1. Allow rules shadowed by deny rules
|
|
70
|
+
shadowed = allow_tools & deny_tools
|
|
71
|
+
for tool in sorted(shadowed):
|
|
72
|
+
warnings.append(
|
|
73
|
+
f"Rule conflict: '{tool}' appears in both allow_tools and deny_tools; "
|
|
74
|
+
"deny takes precedence — allow rule is ineffective."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# 2. Allow rules unnecessary because mode already allows them
|
|
78
|
+
# AUTO_ACCEPT and FULL_ACCESS allow everything; WORKSPACE_WRITE allows up to
|
|
79
|
+
# workspace_write level — but without per-tool level info we can only flag
|
|
80
|
+
# modes that unconditionally allow all non-denied tools.
|
|
81
|
+
unconditional_allow_modes = {PermissionMode.AUTO_ACCEPT, PermissionMode.FULL_ACCESS}
|
|
82
|
+
if mode in unconditional_allow_modes:
|
|
83
|
+
for tool in sorted(allow_tools - deny_tools):
|
|
84
|
+
warnings.append(
|
|
85
|
+
f"Redundant allow rule: '{tool}' is already allowed by mode '{mode.value}'; "
|
|
86
|
+
"explicit allow entry has no effect."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# 3. Deny rules unnecessary because mode already blocks them
|
|
90
|
+
# READ_ONLY blocks WORKSPACE_WRITE and FULL_ACCESS tools; without per-tool
|
|
91
|
+
# level info we flag the case where mode=READ_ONLY and a tool is in deny_tools
|
|
92
|
+
# while also not in allow_tools (i.e. it would be denied by the mode anyway).
|
|
93
|
+
# The most deterministic check: PROMPT mode never auto-allows elevated tools,
|
|
94
|
+
# but it does prompt — so denying explicitly is meaningful there.
|
|
95
|
+
# READ_ONLY mode blocks everything above READ_ONLY already.
|
|
96
|
+
if mode == PermissionMode.READ_ONLY:
|
|
97
|
+
# In READ_ONLY mode all non-read-only tools are blocked anyway.
|
|
98
|
+
# Explicit deny entries for tools that mode would block are redundant.
|
|
99
|
+
# We flag tools that are denied but not in allow_tools (since allow overrides
|
|
100
|
+
# mode, an allow+deny combo is already caught above).
|
|
101
|
+
redundant_denies = deny_tools - allow_tools
|
|
102
|
+
for tool in sorted(redundant_denies):
|
|
103
|
+
warnings.append(
|
|
104
|
+
f"Redundant deny rule: '{tool}' is already blocked by mode 'read_only'; "
|
|
105
|
+
"explicit deny entry has no effect."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
return warnings
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class PermissionPolicy:
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
mode: PermissionMode,
|
|
115
|
+
allow_tools: frozenset[str] = frozenset(),
|
|
116
|
+
deny_tools: frozenset[str] = frozenset(),
|
|
117
|
+
deny_patterns: tuple[str, ...] = (),
|
|
118
|
+
rbac: object | None = None, # RBACEngine, loosely typed to avoid circular import
|
|
119
|
+
) -> None:
|
|
120
|
+
self._mode = mode
|
|
121
|
+
self._allow_tools = allow_tools
|
|
122
|
+
self._deny_tools = deny_tools
|
|
123
|
+
self._deny_patterns = deny_patterns
|
|
124
|
+
self._rbac = rbac
|
|
125
|
+
|
|
126
|
+
# Warn about conflicting or redundant rules at construction time
|
|
127
|
+
for warning in detect_shadowed_rules(allow_tools, deny_tools, mode):
|
|
128
|
+
_log.warning("PermissionPolicy: %s", warning)
|
|
129
|
+
|
|
130
|
+
def authorize(
|
|
131
|
+
self,
|
|
132
|
+
tool_name: str,
|
|
133
|
+
required: PermissionLevel,
|
|
134
|
+
effective_level: PermissionLevel | None = None,
|
|
135
|
+
identity: object | None = None, # AuthIdentity
|
|
136
|
+
) -> PermissionOutcome:
|
|
137
|
+
"""Determine whether a tool invocation is authorized.
|
|
138
|
+
|
|
139
|
+
Precedence:
|
|
140
|
+
0. RBAC check (if engine and identity provided) → DENY
|
|
141
|
+
1. deny_tools / deny_patterns → DENY
|
|
142
|
+
2. allow_tools → ALLOW
|
|
143
|
+
3. AUTO_ACCEPT → always ALLOW
|
|
144
|
+
4. PROMPT mode: READ_ONLY always allowed, elevated → NEED_PROMPT
|
|
145
|
+
5. Other modes: compare effective level vs mode max level
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
tool_name: The name of the tool being authorized.
|
|
149
|
+
required: The tool's declared required permission level.
|
|
150
|
+
effective_level: If provided, used instead of ``required`` for
|
|
151
|
+
level comparisons (e.g. after safety analysis determines the
|
|
152
|
+
actual operation is less or more privileged than declared).
|
|
153
|
+
Deny/allow lists still take full precedence.
|
|
154
|
+
identity: Optional AuthIdentity for RBAC checks.
|
|
155
|
+
"""
|
|
156
|
+
# Use effective_level for comparisons when provided, else fall back to required
|
|
157
|
+
level = effective_level if effective_level is not None else required
|
|
158
|
+
|
|
159
|
+
# 0. RBAC check (if engine and identity provided)
|
|
160
|
+
if self._rbac is not None and identity is not None:
|
|
161
|
+
if not self._rbac.is_allowed(identity, f"tool:{tool_name}"):
|
|
162
|
+
return PermissionOutcome.DENY
|
|
163
|
+
|
|
164
|
+
# 1. Deny list and patterns always win
|
|
165
|
+
if tool_name in self._deny_tools:
|
|
166
|
+
return PermissionOutcome.DENY
|
|
167
|
+
for pattern in self._deny_patterns:
|
|
168
|
+
if fnmatch.fnmatch(tool_name, pattern):
|
|
169
|
+
return PermissionOutcome.DENY
|
|
170
|
+
|
|
171
|
+
# 2. Explicit allow list overrides mode restrictions
|
|
172
|
+
if tool_name in self._allow_tools:
|
|
173
|
+
return PermissionOutcome.ALLOW
|
|
174
|
+
|
|
175
|
+
# 3. AUTO_ACCEPT allows everything
|
|
176
|
+
if self._mode == PermissionMode.AUTO_ACCEPT:
|
|
177
|
+
return PermissionOutcome.ALLOW
|
|
178
|
+
|
|
179
|
+
# 4. PROMPT mode: read-only is always allowed, elevated needs prompt
|
|
180
|
+
if self._mode == PermissionMode.PROMPT:
|
|
181
|
+
if level == PermissionLevel.READ_ONLY:
|
|
182
|
+
return PermissionOutcome.ALLOW
|
|
183
|
+
return PermissionOutcome.NEED_PROMPT
|
|
184
|
+
|
|
185
|
+
# 4b. PLAN mode: read-only always allowed, elevated needs planning confirmation
|
|
186
|
+
if self._mode == PermissionMode.PLAN:
|
|
187
|
+
if level == PermissionLevel.READ_ONLY:
|
|
188
|
+
return PermissionOutcome.ALLOW
|
|
189
|
+
return PermissionOutcome.NEED_PLAN
|
|
190
|
+
|
|
191
|
+
# 5. Level-based comparison for READ_ONLY, WORKSPACE_WRITE, FULL_ACCESS modes
|
|
192
|
+
level_rank = _LEVEL_RANK[level]
|
|
193
|
+
mode_max = _MODE_MAX_LEVEL[self._mode]
|
|
194
|
+
if level_rank <= mode_max:
|
|
195
|
+
return PermissionOutcome.ALLOW
|
|
196
|
+
return PermissionOutcome.DENY
|
|
197
|
+
|
|
198
|
+
def allow_tool(self, tool_name: str) -> None:
|
|
199
|
+
"""Dynamically add a tool to the allow list (e.g. after user approves 'always')."""
|
|
200
|
+
self._allow_tools = self._allow_tools | frozenset({tool_name})
|
llm_code/runtime/plan.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Plan mode data structures for presenting tool operations before execution."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(frozen=True)
|
|
8
|
+
class PlanEntry:
|
|
9
|
+
tool_name: str
|
|
10
|
+
args: dict
|
|
11
|
+
summary: str
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclasses.dataclass(frozen=True)
|
|
15
|
+
class PlanSummary:
|
|
16
|
+
entries: tuple[PlanEntry, ...]
|
|
17
|
+
|
|
18
|
+
def render(self) -> str:
|
|
19
|
+
if not self.entries:
|
|
20
|
+
return "No operations in plan."
|
|
21
|
+
lines = [f"Plan ({len(self.entries)} operations)\n"]
|
|
22
|
+
for i, entry in enumerate(self.entries, 1):
|
|
23
|
+
lines.append(f" {i}. [{entry.tool_name}] {entry.summary}")
|
|
24
|
+
return "\n".join(lines)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def summarize_tool_call(name: str, args: dict) -> str:
|
|
28
|
+
"""Return a human-readable summary of a tool call for plan mode display."""
|
|
29
|
+
if name == "edit_file":
|
|
30
|
+
path = args.get("file_path", "?")
|
|
31
|
+
old = args.get("old_string", "")
|
|
32
|
+
new = args.get("new_string", "")
|
|
33
|
+
old_p = old[:40] + "..." if len(old) > 40 else old
|
|
34
|
+
new_p = new[:40] + "..." if len(new) > 40 else new
|
|
35
|
+
return f"Edit {path}: '{old_p}' -> '{new_p}'"
|
|
36
|
+
if name == "write_file":
|
|
37
|
+
path = args.get("file_path", "?")
|
|
38
|
+
content = args.get("content", "")
|
|
39
|
+
return f"Create {path} ({len(content)} chars)"
|
|
40
|
+
if name == "bash":
|
|
41
|
+
cmd = args.get("command", "?")
|
|
42
|
+
preview = cmd[:60] + "..." if len(cmd) > 60 else cmd
|
|
43
|
+
return f"Run: {preview}"
|
|
44
|
+
params = ", ".join(f"{k}={repr(v)[:30]}" for k, v in list(args.items())[:3])
|
|
45
|
+
return f"{name}({params})"
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""System prompt builder for the conversation runtime."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
from datetime import date
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from llm_code.api.types import ToolDefinition
|
|
12
|
+
from llm_code.runtime.context import ProjectContext
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from llm_code.runtime.indexer import ProjectIndex
|
|
16
|
+
from llm_code.runtime.memory_layers import GovernanceRule
|
|
17
|
+
from llm_code.runtime.skills import SkillSet
|
|
18
|
+
from llm_code.task.manager import TaskLifecycleManager
|
|
19
|
+
|
|
20
|
+
_INTRO = """\
|
|
21
|
+
You are a coding assistant running inside a terminal. \
|
|
22
|
+
You have access to tools that let you read, write, and edit files, \
|
|
23
|
+
search code, and run shell commands. \
|
|
24
|
+
Think step-by-step before taking any action.\
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
_BEHAVIOR_RULES = """\
|
|
28
|
+
Rules:
|
|
29
|
+
- Read code before modifying it
|
|
30
|
+
- Do not add features the user did not ask for
|
|
31
|
+
- Do not add error handling or comments unless asked
|
|
32
|
+
- Do not over-engineer or create unnecessary abstractions
|
|
33
|
+
- Three similar lines of code is better than a premature abstraction
|
|
34
|
+
- If something fails, diagnose why before switching approach
|
|
35
|
+
- Report results honestly — do not claim something works without verifying
|
|
36
|
+
- Keep responses concise — lead with the answer, not the reasoning
|
|
37
|
+
- For code changes, show the minimal diff needed
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_XML_TOOL_INSTRUCTIONS = """\
|
|
41
|
+
When you need to use a tool, emit exactly one JSON block wrapped in \
|
|
42
|
+
<tool_call>...</tool_call> XML tags — nothing else on those lines. \
|
|
43
|
+
The JSON must have two keys: "tool" (the tool name) and "args" (an object \
|
|
44
|
+
of parameters). Example:
|
|
45
|
+
<tool_call>{"tool": "read_file", "args": {"path": "/README.md"}}</tool_call>
|
|
46
|
+
Wait for the tool result before continuing.\
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
_CACHE_BOUNDARY = "# -- CACHE BOUNDARY --"
|
|
50
|
+
|
|
51
|
+
# Cache control marker inserted between scope transitions (API-level caching)
|
|
52
|
+
_CACHE_CONTROL_MARKER = json.dumps({"type": "cache_control", "cache_type": "ephemeral"})
|
|
53
|
+
|
|
54
|
+
ScopeType = Literal["global", "project", "session"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclasses.dataclass(frozen=True)
|
|
58
|
+
class PromptSection:
|
|
59
|
+
"""A single section of the system prompt with scope and priority metadata.
|
|
60
|
+
|
|
61
|
+
Scope semantics:
|
|
62
|
+
- "global": Behavior rules and tool instructions shared across all projects.
|
|
63
|
+
- "project": Governance rules, project index, CLAUDE.md — shared across
|
|
64
|
+
sessions within the same project.
|
|
65
|
+
- "session": Environment info, memory, active skills — per-session content.
|
|
66
|
+
|
|
67
|
+
Priority controls ordering within the same scope (lower value = earlier).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
content: str
|
|
71
|
+
scope: ScopeType
|
|
72
|
+
priority: int = 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class SystemPromptBuilder:
|
|
76
|
+
def build(
|
|
77
|
+
self,
|
|
78
|
+
context: ProjectContext,
|
|
79
|
+
tools: tuple[ToolDefinition, ...] = (),
|
|
80
|
+
native_tools: bool = True,
|
|
81
|
+
skills: "SkillSet | None" = None,
|
|
82
|
+
active_skill_content: str | None = None,
|
|
83
|
+
project_index: "ProjectIndex | None" = None,
|
|
84
|
+
memory_entries: dict | None = None,
|
|
85
|
+
memory_summaries: list[str] | None = None,
|
|
86
|
+
mcp_instructions: dict[str, str] | None = None,
|
|
87
|
+
governance_rules: "tuple[GovernanceRule, ...] | None" = None,
|
|
88
|
+
task_manager: "TaskLifecycleManager | None" = None,
|
|
89
|
+
) -> str:
|
|
90
|
+
sections: list[PromptSection] = []
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------ #
|
|
93
|
+
# GLOBAL scope — governance rules, behavior rules, tool instructions
|
|
94
|
+
# Shared across all projects; cached at global boundary.
|
|
95
|
+
# ------------------------------------------------------------------ #
|
|
96
|
+
sections.append(PromptSection(content=_INTRO, scope="global", priority=0))
|
|
97
|
+
|
|
98
|
+
# Governance rules (L0) — injected before behavior rules
|
|
99
|
+
if governance_rules:
|
|
100
|
+
gov_lines = ["## Governance Rules\n"]
|
|
101
|
+
categories: dict[str, list] = {}
|
|
102
|
+
for rule in governance_rules:
|
|
103
|
+
categories.setdefault(rule.category, []).append(rule)
|
|
104
|
+
for cat, cat_rules in categories.items():
|
|
105
|
+
gov_lines.append(f"### {cat.title()}")
|
|
106
|
+
for r in cat_rules:
|
|
107
|
+
source_name = Path(r.source).name if r.source else "unknown"
|
|
108
|
+
gov_lines.append(f"- {r.content} _(from {source_name})_")
|
|
109
|
+
gov_lines.append("")
|
|
110
|
+
sections.append(PromptSection(content="\n".join(gov_lines), scope="global", priority=5))
|
|
111
|
+
|
|
112
|
+
sections.append(PromptSection(content=_BEHAVIOR_RULES, scope="global", priority=10))
|
|
113
|
+
|
|
114
|
+
if not native_tools and tools:
|
|
115
|
+
sections.append(PromptSection(content=_XML_TOOL_INSTRUCTIONS, scope="global", priority=20))
|
|
116
|
+
tool_lines = ["Available tools:"]
|
|
117
|
+
for t in tools:
|
|
118
|
+
schema_str = json.dumps(t.input_schema, separators=(",", ":"))
|
|
119
|
+
tool_lines.append(f" - {t.name}: {t.description} schema={schema_str}")
|
|
120
|
+
sections.append(PromptSection(content="\n".join(tool_lines), scope="global", priority=21))
|
|
121
|
+
|
|
122
|
+
# Auto skills are relatively stable and treated as global
|
|
123
|
+
if skills and skills.auto_skills:
|
|
124
|
+
auto_parts = ["## Active Skills"]
|
|
125
|
+
for skill in skills.auto_skills:
|
|
126
|
+
auto_parts.append(f"### {skill.name}\n{skill.content}")
|
|
127
|
+
sections.append(PromptSection(content="\n\n".join(auto_parts), scope="global", priority=30))
|
|
128
|
+
|
|
129
|
+
# ------------------------------------------------------------------ #
|
|
130
|
+
# PROJECT scope — project index, CLAUDE.md
|
|
131
|
+
# Shared across sessions in the same project; cached at project boundary.
|
|
132
|
+
# ------------------------------------------------------------------ #
|
|
133
|
+
|
|
134
|
+
# Project index (cache-safe — changes infrequently)
|
|
135
|
+
if project_index:
|
|
136
|
+
_KIND_PRIORITY = {"class": 0, "function": 1, "export": 2, "method": 3, "variable": 4}
|
|
137
|
+
sorted_symbols = sorted(project_index.symbols, key=lambda s: _KIND_PRIORITY.get(s.kind, 99))[:100]
|
|
138
|
+
lines = [f" {s.kind} {s.name} — {s.file}:{s.line}" for s in sorted_symbols]
|
|
139
|
+
sections.append(PromptSection(
|
|
140
|
+
content=f"## Project Index ({len(project_index.files)} files)\n\n" + "\n".join(lines),
|
|
141
|
+
scope="project",
|
|
142
|
+
priority=10,
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
# Project instructions from CLAUDE.md / INSTRUCTIONS.md
|
|
146
|
+
if context.instructions:
|
|
147
|
+
sections.append(PromptSection(
|
|
148
|
+
content=f"## Project Instructions\n\n{context.instructions}",
|
|
149
|
+
scope="project",
|
|
150
|
+
priority=20,
|
|
151
|
+
))
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------ #
|
|
154
|
+
# SESSION scope — environment, memory, active skills (per-session)
|
|
155
|
+
# ------------------------------------------------------------------ #
|
|
156
|
+
|
|
157
|
+
# MCP server instructions (injected per-server, per-session)
|
|
158
|
+
if mcp_instructions:
|
|
159
|
+
for server_name, instr in mcp_instructions.items():
|
|
160
|
+
sections.append(PromptSection(
|
|
161
|
+
content=f"## MCP Server: {server_name}\n\n{instr}",
|
|
162
|
+
scope="session",
|
|
163
|
+
priority=0,
|
|
164
|
+
))
|
|
165
|
+
|
|
166
|
+
# Active command skill (one-shot, dynamic)
|
|
167
|
+
if active_skill_content:
|
|
168
|
+
sections.append(PromptSection(
|
|
169
|
+
content=f"## Active Skill\n\n{active_skill_content}",
|
|
170
|
+
scope="session",
|
|
171
|
+
priority=5,
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
# Environment section (dynamic — cwd, date, git status)
|
|
175
|
+
env_lines = [
|
|
176
|
+
"## Environment",
|
|
177
|
+
f"- Working directory: {context.cwd}",
|
|
178
|
+
f"- Platform: {platform.system()}",
|
|
179
|
+
f"- Date: {date.today().isoformat()}",
|
|
180
|
+
]
|
|
181
|
+
if context.is_git_repo and context.git_status:
|
|
182
|
+
env_lines.append(f"- Git status:\n```\n{context.git_status}\n```")
|
|
183
|
+
elif context.is_git_repo:
|
|
184
|
+
env_lines.append("- Git status: clean")
|
|
185
|
+
sections.append(PromptSection(content="\n".join(env_lines), scope="session", priority=10))
|
|
186
|
+
|
|
187
|
+
# Memory summaries (dynamic — recent session history)
|
|
188
|
+
if memory_summaries:
|
|
189
|
+
sections.append(PromptSection(
|
|
190
|
+
content="## Recent Sessions\n\n" + "\n".join(f"- {s[:200]}" for s in memory_summaries),
|
|
191
|
+
scope="session",
|
|
192
|
+
priority=20,
|
|
193
|
+
))
|
|
194
|
+
|
|
195
|
+
# Memory entries (dynamic — project-scoped key-value memory)
|
|
196
|
+
if memory_entries:
|
|
197
|
+
lines = [f"- **{k}**: {v[:200]}" for k, v in memory_entries.items()]
|
|
198
|
+
sections.append(PromptSection(
|
|
199
|
+
content="## Project Memory\n\n" + "\n".join(lines),
|
|
200
|
+
scope="session",
|
|
201
|
+
priority=21,
|
|
202
|
+
))
|
|
203
|
+
|
|
204
|
+
# Incomplete tasks from prior sessions (cross-session persistence)
|
|
205
|
+
if task_manager is not None:
|
|
206
|
+
from llm_code.task.manager import build_incomplete_tasks_prompt
|
|
207
|
+
task_section = build_incomplete_tasks_prompt(task_manager)
|
|
208
|
+
if task_section:
|
|
209
|
+
sections.append(PromptSection(content=task_section, scope="session", priority=30))
|
|
210
|
+
|
|
211
|
+
return self._serialize(sections)
|
|
212
|
+
|
|
213
|
+
def _serialize(self, sections: list[PromptSection]) -> str:
|
|
214
|
+
"""Serialize PromptSection list into a single string with cache boundary markers.
|
|
215
|
+
|
|
216
|
+
Sections are grouped by scope and sorted by priority within each scope.
|
|
217
|
+
Cache boundary markers are inserted between scope transitions:
|
|
218
|
+
- Between global and project scopes
|
|
219
|
+
- Between project and session scopes
|
|
220
|
+
|
|
221
|
+
This allows API-level caching at two boundaries instead of one.
|
|
222
|
+
"""
|
|
223
|
+
scope_order: dict[ScopeType, int] = {"global": 0, "project": 1, "session": 2}
|
|
224
|
+
sorted_sections = sorted(sections, key=lambda s: (scope_order[s.scope], s.priority))
|
|
225
|
+
|
|
226
|
+
parts: list[str] = []
|
|
227
|
+
prev_scope: ScopeType | None = None
|
|
228
|
+
|
|
229
|
+
for section in sorted_sections:
|
|
230
|
+
current_scope = section.scope
|
|
231
|
+
if prev_scope is not None and current_scope != prev_scope:
|
|
232
|
+
# Insert cache boundary marker between scope transitions
|
|
233
|
+
parts.append(_CACHE_BOUNDARY)
|
|
234
|
+
parts.append(_CACHE_CONTROL_MARKER)
|
|
235
|
+
parts.append(section.content)
|
|
236
|
+
prev_scope = current_scope
|
|
237
|
+
|
|
238
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Repo Map -- AST-based symbol index for codebase overview."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
_SKIP_DIRS = frozenset({
|
|
13
|
+
".git", "__pycache__", "node_modules", ".venv", "venv",
|
|
14
|
+
".tox", ".mypy_cache", ".pytest_cache", ".ruff_cache",
|
|
15
|
+
"dist", "build", ".eggs",
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
_PYTHON_EXTS = frozenset({".py", ".pyi"})
|
|
19
|
+
_JS_TS_EXTS = frozenset({".js", ".jsx", ".ts", ".tsx"})
|
|
20
|
+
|
|
21
|
+
_BINARY_EXTS = frozenset({
|
|
22
|
+
".pyc", ".pyo", ".so", ".dll", ".png", ".jpg", ".jpeg",
|
|
23
|
+
".gif", ".bmp", ".ico", ".zip", ".gz", ".tar", ".whl",
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class ClassSymbol:
|
|
29
|
+
"""A class with its public method names."""
|
|
30
|
+
|
|
31
|
+
name: str
|
|
32
|
+
methods: tuple[str, ...] = ()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FileSymbols:
|
|
37
|
+
"""Symbols extracted from a single file."""
|
|
38
|
+
|
|
39
|
+
path: str
|
|
40
|
+
classes: tuple[ClassSymbol, ...] = ()
|
|
41
|
+
functions: tuple[str, ...] = ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class RepoMap:
|
|
46
|
+
"""Immutable collection of per-file symbol summaries."""
|
|
47
|
+
|
|
48
|
+
files: tuple[FileSymbols, ...] = ()
|
|
49
|
+
|
|
50
|
+
def to_compact(self, max_tokens: int = 2000) -> str:
|
|
51
|
+
"""Render a compact text representation of the repo map.
|
|
52
|
+
|
|
53
|
+
Stays within approximately *max_tokens* (estimated as chars / 4).
|
|
54
|
+
"""
|
|
55
|
+
max_chars = max_tokens * 4
|
|
56
|
+
lines: list[str] = []
|
|
57
|
+
total_chars = 0
|
|
58
|
+
|
|
59
|
+
for fs in self.files:
|
|
60
|
+
if not fs.classes and not fs.functions:
|
|
61
|
+
line = fs.path
|
|
62
|
+
else:
|
|
63
|
+
symbols: list[str] = []
|
|
64
|
+
for cls in fs.classes:
|
|
65
|
+
if cls.methods:
|
|
66
|
+
symbols.append(f"{cls.name}({', '.join(cls.methods)})")
|
|
67
|
+
else:
|
|
68
|
+
symbols.append(cls.name)
|
|
69
|
+
symbols.extend(fs.functions)
|
|
70
|
+
line = f"{fs.path}: {', '.join(symbols)}"
|
|
71
|
+
|
|
72
|
+
line_len = len(line) + 1 # +1 for newline
|
|
73
|
+
if total_chars + line_len > max_chars:
|
|
74
|
+
break
|
|
75
|
+
lines.append(line)
|
|
76
|
+
total_chars += line_len
|
|
77
|
+
|
|
78
|
+
return "\n".join(lines)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def build_repo_map(cwd: Path, max_files: int = 100) -> RepoMap:
|
|
82
|
+
"""Build a symbol map of the repository rooted at *cwd*."""
|
|
83
|
+
source_files: list[Path] = []
|
|
84
|
+
_collect_source_files(cwd, cwd, source_files)
|
|
85
|
+
source_files.sort(key=lambda p: str(p.relative_to(cwd)))
|
|
86
|
+
|
|
87
|
+
file_symbols: list[FileSymbols] = []
|
|
88
|
+
for f in source_files[:max_files]:
|
|
89
|
+
rel = str(f.relative_to(cwd))
|
|
90
|
+
suffix = f.suffix.lower()
|
|
91
|
+
|
|
92
|
+
if suffix in _PYTHON_EXTS:
|
|
93
|
+
fs = _parse_python(f, rel)
|
|
94
|
+
elif suffix in _JS_TS_EXTS:
|
|
95
|
+
fs = _parse_js_ts(f, rel)
|
|
96
|
+
else:
|
|
97
|
+
fs = FileSymbols(path=rel)
|
|
98
|
+
|
|
99
|
+
file_symbols.append(fs)
|
|
100
|
+
|
|
101
|
+
return RepoMap(files=tuple(file_symbols))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _collect_source_files(
|
|
105
|
+
base: Path, current: Path, out: list[Path],
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Recursively collect source files, skipping irrelevant directories."""
|
|
108
|
+
try:
|
|
109
|
+
entries = sorted(current.iterdir(), key=lambda p: p.name)
|
|
110
|
+
except PermissionError:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
for entry in entries:
|
|
114
|
+
if entry.is_dir():
|
|
115
|
+
if entry.name in _SKIP_DIRS or entry.name.startswith("."):
|
|
116
|
+
continue
|
|
117
|
+
_collect_source_files(base, entry, out)
|
|
118
|
+
elif entry.is_file():
|
|
119
|
+
if entry.suffix.lower() in _BINARY_EXTS:
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
if entry.stat().st_size > 100_000:
|
|
123
|
+
continue
|
|
124
|
+
except OSError:
|
|
125
|
+
continue
|
|
126
|
+
out.append(entry)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parse_python(path: Path, rel_path: str) -> FileSymbols:
|
|
130
|
+
"""Parse a Python file using AST to extract classes and functions."""
|
|
131
|
+
try:
|
|
132
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
133
|
+
tree = ast.parse(source, filename=rel_path)
|
|
134
|
+
except (SyntaxError, OSError):
|
|
135
|
+
return FileSymbols(path=rel_path)
|
|
136
|
+
|
|
137
|
+
classes: list[ClassSymbol] = []
|
|
138
|
+
functions: list[str] = []
|
|
139
|
+
|
|
140
|
+
for node in ast.iter_child_nodes(tree):
|
|
141
|
+
if isinstance(node, ast.ClassDef):
|
|
142
|
+
methods = tuple(
|
|
143
|
+
item.name
|
|
144
|
+
for item in ast.iter_child_nodes(node)
|
|
145
|
+
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
146
|
+
and not item.name.startswith("_")
|
|
147
|
+
)
|
|
148
|
+
classes.append(ClassSymbol(name=node.name, methods=methods))
|
|
149
|
+
elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
150
|
+
if not node.name.startswith("_"):
|
|
151
|
+
functions.append(node.name)
|
|
152
|
+
|
|
153
|
+
return FileSymbols(path=rel_path, classes=tuple(classes), functions=tuple(functions))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _parse_js_ts(path: Path, rel_path: str) -> FileSymbols:
|
|
157
|
+
"""Parse JS/TS file using regex fallback for class/function extraction."""
|
|
158
|
+
try:
|
|
159
|
+
source = path.read_text(encoding="utf-8", errors="replace")
|
|
160
|
+
except OSError:
|
|
161
|
+
return FileSymbols(path=rel_path)
|
|
162
|
+
|
|
163
|
+
classes: list[ClassSymbol] = []
|
|
164
|
+
functions: list[str] = []
|
|
165
|
+
|
|
166
|
+
for match in re.finditer(r"class\s+(\w+)", source):
|
|
167
|
+
classes.append(ClassSymbol(name=match.group(1)))
|
|
168
|
+
|
|
169
|
+
for match in re.finditer(r"(?:export\s+)?function\s+(\w+)", source):
|
|
170
|
+
functions.append(match.group(1))
|
|
171
|
+
for match in re.finditer(r"export\s+const\s+(\w+)", source):
|
|
172
|
+
functions.append(match.group(1))
|
|
173
|
+
|
|
174
|
+
return FileSymbols(path=rel_path, classes=tuple(classes), functions=tuple(functions))
|