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,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})
@@ -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))