k-cli-for-devs 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.
- k_cli/__init__.py +77 -0
- k_cli/agents/__init__.py +0 -0
- k_cli/agents/adversarial_swarm.py +338 -0
- k_cli/agents/agent_core.py +255 -0
- k_cli/agents/background_daemon.py +141 -0
- k_cli/agents/orchestrator.py +376 -0
- k_cli/agents/persona.py +649 -0
- k_cli/agents/scaffold_engine.py +121 -0
- k_cli/agents/strands_agent.py +832 -0
- k_cli/agents/subagents.py +1496 -0
- k_cli/cli.py +3297 -0
- k_cli/core/__init__.py +0 -0
- k_cli/core/airgap.py +95 -0
- k_cli/core/credentials.py +548 -0
- k_cli/core/intent_sensor.py +177 -0
- k_cli/core/llm_driver.py +1028 -0
- k_cli/core/model_manager.py +1109 -0
- k_cli/core/models_hub.py +913 -0
- k_cli/core/prompting.py +41 -0
- k_cli/core/sdk.py +322 -0
- k_cli/core/session.py +826 -0
- k_cli/core/smart_router.py +230 -0
- k_cli/core/storage_manager.py +176 -0
- k_cli/core/viewport_engine.py +117 -0
- k_cli/demo/demo_runner.py +579 -0
- k_cli/git/__init__.py +0 -0
- k_cli/git/ai_bisect.py +208 -0
- k_cli/git/conflict_resolver.py +1039 -0
- k_cli/git/git_guard.py +417 -0
- k_cli/git/patcher.py +1175 -0
- k_cli/git/repo_map.py +1780 -0
- k_cli/git/smart_git.py +928 -0
- k_cli/git/verifier.py +969 -0
- k_cli/github/__init__.py +0 -0
- k_cli/github/dedup_engine.py +787 -0
- k_cli/github/github_client.py +1702 -0
- k_cli/github/github_engine.py +641 -0
- k_cli/github/local_hub.py +209 -0
- k_cli/github/pr_watcher.py +129 -0
- k_cli/github/trending.py +205 -0
- k_cli/tools/__init__.py +0 -0
- k_cli/tools/audit.py +79 -0
- k_cli/tools/chaos_immunity.py +377 -0
- k_cli/tools/codebase_qa.py +106 -0
- k_cli/tools/command_runner.py +256 -0
- k_cli/tools/diagram_generator.py +547 -0
- k_cli/tools/doc_retriever.py +1332 -0
- k_cli/tools/feature.py +105 -0
- k_cli/tools/ghost_daemon.py +122 -0
- k_cli/tools/incident_triage.py +1365 -0
- k_cli/tools/mcp_client.py +1846 -0
- k_cli/tools/repo_gardener.py +142 -0
- k_cli/tools/rules.py +109 -0
- k_cli/tools/security.py +52 -0
- k_cli/tools/security_healer.py +999 -0
- k_cli/tools/synapse_graph.py +155 -0
- k_cli/tui/__init__.py +0 -0
- k_cli/tui/diff_viewer.py +223 -0
- k_cli/tui/tui.py +1145 -0
- k_cli/tui/tui_animations.py +648 -0
- k_cli/tui/tui_app.py +2788 -0
- k_cli/ui/__init__.py +10 -0
- k_cli/ui/simple_repl.py +315 -0
- k_cli/web/__init__.py +7 -0
- k_cli/web/server.py +624 -0
- k_cli/web/static/app.js +830 -0
- k_cli/web/static/index.html +495 -0
- k_cli/web/static/monitor.html +189 -0
- k_cli/web/static/style.css +838 -0
- k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
- k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
- k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
- k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
- k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
- k_cli_for_devs-1.0.0.dist-info/top_level.txt +1 -0
k_cli/core/session.py
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
"""
|
|
2
|
+
session.py - Interactive Multi-Turn Session & Command Hub for K-CLI (Project Bankai Engine v1.0.0)
|
|
3
|
+
|
|
4
|
+
Manages multi-turn conversation state, rolling token budgeting, active file context tracking,
|
|
5
|
+
slash command dispatching, DevDocs injection, AST repo map integration, surgical patch application,
|
|
6
|
+
and Git-guarded safety net operations.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import gc
|
|
14
|
+
import psutil
|
|
15
|
+
import queue
|
|
16
|
+
import threading
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple, Union
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from k_cli.tools.doc_retriever import DocRetriever
|
|
22
|
+
from k_cli.git.repo_map import RepoMap
|
|
23
|
+
from k_cli.git.patcher import Patcher
|
|
24
|
+
from k_cli.git.git_guard import GitGuard
|
|
25
|
+
from k_cli.git.verifier import Verifier, CodeExtractor
|
|
26
|
+
from k_cli.agents.orchestrator import Orchestrator, Persona, OrchestratorResult
|
|
27
|
+
from k_cli.core.llm_driver import LLMDriver
|
|
28
|
+
from k_cli.agents.persona import DomainPersona, PersonaProfile, PersonaRegistry
|
|
29
|
+
except (ModuleNotFoundError, ImportError):
|
|
30
|
+
try:
|
|
31
|
+
from doc_retriever import DocRetriever
|
|
32
|
+
from repo_map import RepoMap
|
|
33
|
+
from patcher import Patcher
|
|
34
|
+
from git_guard import GitGuard
|
|
35
|
+
from verifier import Verifier, CodeExtractor
|
|
36
|
+
from orchestrator import Orchestrator, Persona, OrchestratorResult
|
|
37
|
+
from k_cli.core.llm_driver import LLMDriver
|
|
38
|
+
from persona import DomainPersona, PersonaProfile, PersonaRegistry
|
|
39
|
+
except (ModuleNotFoundError, ImportError):
|
|
40
|
+
from doc_retriever import DocRetriever
|
|
41
|
+
from repo_map import RepoMap
|
|
42
|
+
from patcher import Patcher
|
|
43
|
+
from git_guard import GitGuard
|
|
44
|
+
from verifier import Verifier, CodeExtractor
|
|
45
|
+
from orchestrator import Orchestrator, Persona, OrchestratorResult
|
|
46
|
+
from k_cli.core.llm_driver import LLMDriver
|
|
47
|
+
PersonaProfile = Any # type: ignore
|
|
48
|
+
PersonaRegistry = None # type: ignore
|
|
49
|
+
DomainPersona = None # type: ignore
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SessionManager:
|
|
53
|
+
"""
|
|
54
|
+
Manages interactive multi-turn session state, active file context, rolling token budget,
|
|
55
|
+
slash commands routing, and coordinates Knowledge, Modification, and Core Engine layers.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
workspace_dir: str = ".",
|
|
61
|
+
model_name: Optional[str] = None,
|
|
62
|
+
max_tokens: int = 4096,
|
|
63
|
+
driver: Optional[LLMDriver] = None,
|
|
64
|
+
verifier: Optional[Verifier] = None,
|
|
65
|
+
doc_retriever: Optional[DocRetriever] = None,
|
|
66
|
+
repo_map: Optional[RepoMap] = None,
|
|
67
|
+
patcher: Optional[Patcher] = None,
|
|
68
|
+
git_guard: Optional[GitGuard] = None,
|
|
69
|
+
orchestrator: Optional[Orchestrator] = None,
|
|
70
|
+
mock_mode: bool = False,
|
|
71
|
+
persona: Optional[Union[str, PersonaProfile]] = None,
|
|
72
|
+
):
|
|
73
|
+
self.workspace_dir = Path(workspace_dir).resolve()
|
|
74
|
+
self.model_name = model_name or "qwen2.5-coder:1.5b"
|
|
75
|
+
self.max_tokens = max(1, max_tokens)
|
|
76
|
+
mock_env = os.getenv("KCLI_MOCK_MODE", "").lower() in ("true", "1") or ("PYTEST_CURRENT_TEST" in os.environ and not os.getenv("K_CLI_REAL_LLM"))
|
|
77
|
+
self.mock_mode = mock_mode or mock_env
|
|
78
|
+
|
|
79
|
+
# Components
|
|
80
|
+
self.driver = driver or LLMDriver(model_name=self.model_name, mock_mode=self.mock_mode)
|
|
81
|
+
self.verifier = verifier or Verifier()
|
|
82
|
+
self.doc_retriever = doc_retriever or DocRetriever()
|
|
83
|
+
self.repo_map = repo_map or RepoMap(root_dir=str(self.workspace_dir))
|
|
84
|
+
self.patcher = patcher or Patcher()
|
|
85
|
+
self.git_guard = git_guard or GitGuard(repo_dir=str(self.workspace_dir))
|
|
86
|
+
try:
|
|
87
|
+
from k_cli.github.dedup_engine import DedupEngine
|
|
88
|
+
dedup_eng = DedupEngine(repo_path=str(self.workspace_dir))
|
|
89
|
+
except Exception:
|
|
90
|
+
dedup_eng = None
|
|
91
|
+
self.orchestrator = orchestrator or Orchestrator(driver=self.driver, verifier=self.verifier, dedup_engine=dedup_eng)
|
|
92
|
+
|
|
93
|
+
# Context & History State
|
|
94
|
+
self.context_files: List[str] = []
|
|
95
|
+
self.history: List[Dict[str, Any]] = []
|
|
96
|
+
self.last_result: Optional[Dict[str, Any]] = None
|
|
97
|
+
self._last_snapshot: Optional[str] = None
|
|
98
|
+
|
|
99
|
+
# Dynamic Persona Profile State
|
|
100
|
+
self.active_persona_profile: Optional[PersonaProfile] = None
|
|
101
|
+
if persona is not None:
|
|
102
|
+
if isinstance(persona, PersonaProfile):
|
|
103
|
+
self.active_persona_profile = persona
|
|
104
|
+
elif PersonaRegistry:
|
|
105
|
+
self.active_persona_profile = PersonaRegistry.get(persona) or PersonaRegistry.get_default()
|
|
106
|
+
elif PersonaRegistry:
|
|
107
|
+
self.active_persona_profile = PersonaRegistry.get_default()
|
|
108
|
+
|
|
109
|
+
self.active_persona: str = self.active_persona_profile.title if self.active_persona_profile else "AUTO"
|
|
110
|
+
if self.active_persona_profile and self.orchestrator:
|
|
111
|
+
self.orchestrator.set_persona(self.active_persona_profile)
|
|
112
|
+
|
|
113
|
+
def set_persona(self, persona_query: str) -> Tuple[bool, str]:
|
|
114
|
+
"""
|
|
115
|
+
Switches active persona by name, alias, or pipeline phase.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
persona_query: Query string or persona name (e.g. 'devops', 'surgical debugger', 'systems').
|
|
119
|
+
|
|
120
|
+
Returns:
|
|
121
|
+
Tuple[bool, str]: (success, status_message)
|
|
122
|
+
"""
|
|
123
|
+
if not persona_query or not persona_query.strip():
|
|
124
|
+
if PersonaRegistry:
|
|
125
|
+
return True, PersonaRegistry.format_persona_table(self.active_persona_profile.id if self.active_persona_profile else None)
|
|
126
|
+
return True, f"Active persona: [{self.active_persona}]"
|
|
127
|
+
|
|
128
|
+
clean_query = persona_query.strip()
|
|
129
|
+
|
|
130
|
+
# Check classic pipeline stage names first
|
|
131
|
+
classic_stages = ["AUTO", "RESEARCHER", "ARCHITECT", "CODER", "CRITIC", "DEBUGGER", "DEFAULT"]
|
|
132
|
+
query_upper = clean_query.upper()
|
|
133
|
+
if query_upper in classic_stages:
|
|
134
|
+
self.active_persona = query_upper
|
|
135
|
+
if PersonaRegistry:
|
|
136
|
+
self.active_persona_profile = PersonaRegistry.get(query_upper) or PersonaRegistry.get_default()
|
|
137
|
+
if self.orchestrator:
|
|
138
|
+
self.orchestrator.set_persona(self.active_persona_profile)
|
|
139
|
+
return True, f"Active persona set to [{self.active_persona}]."
|
|
140
|
+
|
|
141
|
+
# Check PersonaRegistry for domain persona match
|
|
142
|
+
if PersonaRegistry:
|
|
143
|
+
matched_profile = PersonaRegistry.get(clean_query)
|
|
144
|
+
if matched_profile is not None:
|
|
145
|
+
self.active_persona_profile = matched_profile
|
|
146
|
+
self.active_persona = matched_profile.title
|
|
147
|
+
if self.orchestrator:
|
|
148
|
+
self.orchestrator.set_persona(matched_profile)
|
|
149
|
+
return True, f"Switched active persona to [{matched_profile.title}] (/{matched_profile.id})."
|
|
150
|
+
|
|
151
|
+
# If unknown, return helpful list of valid options
|
|
152
|
+
if PersonaRegistry:
|
|
153
|
+
table_msg = PersonaRegistry.format_persona_table(self.active_persona_profile.id if self.active_persona_profile else None)
|
|
154
|
+
return False, f"Unknown persona '{persona_query}'.\n\n{table_msg}"
|
|
155
|
+
|
|
156
|
+
return False, f"Unknown persona '{persona_query}'."
|
|
157
|
+
|
|
158
|
+
def get_persona(self) -> str:
|
|
159
|
+
"""Returns the name of the currently active persona."""
|
|
160
|
+
return self.active_persona
|
|
161
|
+
|
|
162
|
+
def get_git_branch(self) -> str:
|
|
163
|
+
"""Returns the current active git branch name."""
|
|
164
|
+
if hasattr(self.git_guard, "get_current_branch"):
|
|
165
|
+
return self.git_guard.get_current_branch()
|
|
166
|
+
return "main" if self.git_guard.is_git_repo() else "no-git"
|
|
167
|
+
|
|
168
|
+
# --------------------------------------------------------------------------
|
|
169
|
+
# File Context Management
|
|
170
|
+
# --------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def add_file(self, file_path: str) -> bool:
|
|
173
|
+
"""
|
|
174
|
+
Adds a file to the active session context.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
file_path: Relative or absolute path to the file.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if file exists and was added (or is already present), False if file does not exist.
|
|
181
|
+
"""
|
|
182
|
+
if not file_path:
|
|
183
|
+
return False
|
|
184
|
+
|
|
185
|
+
target_path = Path(file_path)
|
|
186
|
+
if not target_path.is_absolute():
|
|
187
|
+
target_path = self.workspace_dir / target_path
|
|
188
|
+
|
|
189
|
+
if not target_path.exists() or not target_path.is_file():
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
rel_path = str(target_path.relative_to(self.workspace_dir))
|
|
194
|
+
except ValueError:
|
|
195
|
+
rel_path = str(target_path.resolve())
|
|
196
|
+
|
|
197
|
+
if rel_path not in self.context_files:
|
|
198
|
+
self.context_files.append(rel_path)
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
def remove_file(self, file_path: str) -> bool:
|
|
202
|
+
"""
|
|
203
|
+
Removes a file from the active session context.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
file_path: Relative or absolute path to the file.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
True if file was tracked and removed, False otherwise.
|
|
210
|
+
"""
|
|
211
|
+
if not file_path:
|
|
212
|
+
return False
|
|
213
|
+
|
|
214
|
+
candidates = [file_path]
|
|
215
|
+
target_path = Path(file_path)
|
|
216
|
+
if not target_path.is_absolute():
|
|
217
|
+
abs_p = self.workspace_dir / target_path
|
|
218
|
+
try:
|
|
219
|
+
candidates.append(str(abs_p.relative_to(self.workspace_dir)))
|
|
220
|
+
except ValueError:
|
|
221
|
+
pass
|
|
222
|
+
candidates.append(str(abs_p.resolve()))
|
|
223
|
+
else:
|
|
224
|
+
try:
|
|
225
|
+
candidates.append(str(target_path.relative_to(self.workspace_dir)))
|
|
226
|
+
except ValueError:
|
|
227
|
+
pass
|
|
228
|
+
candidates.append(str(target_path.resolve()))
|
|
229
|
+
|
|
230
|
+
for c in candidates:
|
|
231
|
+
if c in self.context_files:
|
|
232
|
+
self.context_files.remove(c)
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
for f in list(self.context_files):
|
|
236
|
+
if f == file_path or f.endswith("/" + file_path) or Path(f).name == file_path:
|
|
237
|
+
self.context_files.remove(f)
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def get_context_files(self) -> List[str]:
|
|
243
|
+
"""Returns the list of currently tracked context files."""
|
|
244
|
+
return list(self.context_files)
|
|
245
|
+
|
|
246
|
+
# --------------------------------------------------------------------------
|
|
247
|
+
# History & Memory Management
|
|
248
|
+
# --------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def clear_history(self) -> None:
|
|
251
|
+
"""Clears conversation history turns."""
|
|
252
|
+
self.history.clear()
|
|
253
|
+
|
|
254
|
+
def reset_context(self) -> None:
|
|
255
|
+
"""Resets conversation history and context files."""
|
|
256
|
+
self.history.clear()
|
|
257
|
+
self.context_files.clear()
|
|
258
|
+
|
|
259
|
+
def _estimate_tokens(self, text: str) -> int:
|
|
260
|
+
"""Estimates token count of a given string (approx 1 token per word)."""
|
|
261
|
+
if not text:
|
|
262
|
+
return 0
|
|
263
|
+
return max(1, len(text.split()))
|
|
264
|
+
|
|
265
|
+
def _calculate_current_tokens(self) -> int:
|
|
266
|
+
"""Calculates total estimated tokens in current history and context files."""
|
|
267
|
+
total = 0
|
|
268
|
+
for turn in self.history:
|
|
269
|
+
total += self._estimate_tokens(turn.get("prompt", ""))
|
|
270
|
+
total += self._estimate_tokens(turn.get("response", ""))
|
|
271
|
+
total += self._estimate_tokens(turn.get("code", ""))
|
|
272
|
+
|
|
273
|
+
for cf in self.context_files:
|
|
274
|
+
fp = self.workspace_dir / cf
|
|
275
|
+
if fp.exists() and fp.is_file():
|
|
276
|
+
try:
|
|
277
|
+
total += self._estimate_tokens(fp.read_text(encoding="utf-8"))
|
|
278
|
+
except Exception:
|
|
279
|
+
pass
|
|
280
|
+
return total
|
|
281
|
+
|
|
282
|
+
def _prune_history_if_needed(self) -> None:
|
|
283
|
+
"""Prunes oldest history turns if total tokens exceed max_tokens budget."""
|
|
284
|
+
while len(self.history) > 1 and self._calculate_current_tokens() > self.max_tokens:
|
|
285
|
+
self.history.pop(0)
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _get_current_ram_mb() -> float:
|
|
289
|
+
"""Returns current process memory consumption in Megabytes (RSS)."""
|
|
290
|
+
process = psutil.Process()
|
|
291
|
+
return process.memory_info().rss / (1024 * 1024)
|
|
292
|
+
|
|
293
|
+
def get_status(self) -> Dict[str, Any]:
|
|
294
|
+
"""
|
|
295
|
+
Returns status dictionary with active model, context files, tokens, and RAM.
|
|
296
|
+
"""
|
|
297
|
+
ram_mb = self._get_current_ram_mb()
|
|
298
|
+
token_count = self._calculate_current_tokens()
|
|
299
|
+
return {
|
|
300
|
+
"model": self.model_name,
|
|
301
|
+
"model_name": self.model_name,
|
|
302
|
+
"persona": self.active_persona,
|
|
303
|
+
"active_persona": self.active_persona,
|
|
304
|
+
"git_branch": self.get_git_branch(),
|
|
305
|
+
"context_files": list(self.context_files),
|
|
306
|
+
"files": list(self.context_files),
|
|
307
|
+
"turns": len(self.history),
|
|
308
|
+
"history_len": len(self.history),
|
|
309
|
+
"token_count": token_count,
|
|
310
|
+
"tokens": token_count,
|
|
311
|
+
"max_tokens": self.max_tokens,
|
|
312
|
+
"ram_mb": ram_mb,
|
|
313
|
+
"rss_mb": ram_mb,
|
|
314
|
+
"is_git_repo": self.git_guard.is_git_repo(),
|
|
315
|
+
"uncommitted_diff": bool(self.git_guard.get_diff().strip()) if self.git_guard.is_git_repo() else False,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
def set_model(self, model_name: str) -> None:
|
|
319
|
+
"""Switches active model and re-initializes LLM driver."""
|
|
320
|
+
self.model_name = model_name
|
|
321
|
+
self.driver = LLMDriver(model_name=self.model_name, mock_mode=self.mock_mode)
|
|
322
|
+
self.orchestrator = Orchestrator(driver=self.driver, verifier=self.verifier)
|
|
323
|
+
|
|
324
|
+
def run_test(self, target: Optional[str] = None) -> Tuple[bool, str]:
|
|
325
|
+
"""
|
|
326
|
+
Runs ground-truth verification on a specified file, inline code, or workspace test.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Tuple[bool, str]: (passed, result_summary)
|
|
330
|
+
"""
|
|
331
|
+
if target:
|
|
332
|
+
target_path = Path(target)
|
|
333
|
+
if not target_path.is_absolute():
|
|
334
|
+
target_path = self.workspace_dir / target_path
|
|
335
|
+
if target_path.exists() and target_path.is_file():
|
|
336
|
+
ext = target_path.suffix.lstrip(".").lower()
|
|
337
|
+
lang = "python" if ext in ("py", "python") else "bash" if ext in ("sh", "bash") else "cpp" if ext in ("cpp", "cxx", "cc") else "python"
|
|
338
|
+
code = target_path.read_text(encoding="utf-8")
|
|
339
|
+
res = self.verifier.verify(code, language=lang)
|
|
340
|
+
if res.success:
|
|
341
|
+
return True, f"✔ Target '{target}' passed ground-truth verification ({res.verification_type})."
|
|
342
|
+
err = res.error_trace or "Verification failed."
|
|
343
|
+
return False, f"✘ Target '{target}' failed verification at line {res.line_number or 'unknown'}:\n{err}"
|
|
344
|
+
else:
|
|
345
|
+
# Target is inline code
|
|
346
|
+
res = self.verifier.verify(target, language="python")
|
|
347
|
+
if res.success:
|
|
348
|
+
return True, f"✔ Inline code passed ground-truth verification ({res.verification_type})."
|
|
349
|
+
err = res.error_trace or "Verification failed."
|
|
350
|
+
return False, f"✘ Inline code failed verification at line {res.line_number or 'unknown'}:\n{err}"
|
|
351
|
+
|
|
352
|
+
# If no target specified, verify tracked context files
|
|
353
|
+
if self.context_files:
|
|
354
|
+
failed = []
|
|
355
|
+
for cf in self.context_files:
|
|
356
|
+
tf = self.workspace_dir / cf
|
|
357
|
+
if tf.exists() and tf.is_file():
|
|
358
|
+
res = self.verifier.verify(tf.read_text(encoding="utf-8"), language="python")
|
|
359
|
+
if not res.success:
|
|
360
|
+
failed.append(f"{cf}: {res.error_trace or 'verification failed'}")
|
|
361
|
+
if not failed:
|
|
362
|
+
return True, f"✔ All {len(self.context_files)} context file(s) passed verification."
|
|
363
|
+
return False, f"✘ Verification failed for {len(failed)} file(s):\n" + "\n".join(failed)
|
|
364
|
+
|
|
365
|
+
return True, "No specific target or context files provided to test."
|
|
366
|
+
|
|
367
|
+
# --------------------------------------------------------------------------
|
|
368
|
+
# Git Undo & Diff
|
|
369
|
+
# --------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
def undo_last_edit(self) -> Tuple[bool, str]:
|
|
372
|
+
"""
|
|
373
|
+
Rolls back the last modification using GitGuard.
|
|
374
|
+
|
|
375
|
+
Returns:
|
|
376
|
+
Tuple[bool, str]: (success, status_message)
|
|
377
|
+
"""
|
|
378
|
+
if not self.git_guard.is_git_repo():
|
|
379
|
+
return False, "Not inside a Git repository; cannot undo."
|
|
380
|
+
|
|
381
|
+
diff = self.git_guard.get_diff()
|
|
382
|
+
res_status = self.git_guard._run_git(["status", "--porcelain"])
|
|
383
|
+
has_changes = bool(diff.strip() or (res_status.returncode == 0 and res_status.stdout.strip()))
|
|
384
|
+
|
|
385
|
+
if not has_changes:
|
|
386
|
+
return False, "No uncommitted changes to undo (working tree is clean)."
|
|
387
|
+
|
|
388
|
+
success = self.git_guard.rollback()
|
|
389
|
+
if success:
|
|
390
|
+
return True, "Successfully rolled back uncommitted changes."
|
|
391
|
+
return False, "Rollback failed."
|
|
392
|
+
|
|
393
|
+
# --------------------------------------------------------------------------
|
|
394
|
+
# Slash Commands Routing
|
|
395
|
+
# --------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
def handle_slash_command(self, command_str: str) -> Tuple[bool, str]:
|
|
398
|
+
"""
|
|
399
|
+
Parses and handles slash commands.
|
|
400
|
+
|
|
401
|
+
Commands supported:
|
|
402
|
+
/add <file> Add file to active session context
|
|
403
|
+
/remove <file> Remove file from active session context
|
|
404
|
+
/undo Roll back last uncommitted edit via Git
|
|
405
|
+
/rollback [file] Roll back last uncommitted edit via Git (alias /undo)
|
|
406
|
+
/diff View uncommitted git diff in workspace
|
|
407
|
+
/clear Reset conversation history and context files
|
|
408
|
+
/status Display active model, context files, tokens, and RAM
|
|
409
|
+
/model [name] Switch active model or view current model
|
|
410
|
+
/persona [name] Switch active persona or view current persona
|
|
411
|
+
/doc <query> Search DevDocs offline documentation index
|
|
412
|
+
/docs <query> Search DevDocs offline documentation index (alias /doc)
|
|
413
|
+
/test [target] Run ground-truth verification on file/code
|
|
414
|
+
/map Display workspace AST symbol repository map
|
|
415
|
+
/help Show help message
|
|
416
|
+
/exit, /quit Exit session
|
|
417
|
+
|
|
418
|
+
Returns:
|
|
419
|
+
Tuple[bool, str]: (handled, output_message)
|
|
420
|
+
"""
|
|
421
|
+
raw = command_str.strip()
|
|
422
|
+
if not raw:
|
|
423
|
+
return False, ""
|
|
424
|
+
|
|
425
|
+
is_slash = raw.startswith("/")
|
|
426
|
+
cmd_text = raw[1:].strip() if is_slash else raw
|
|
427
|
+
parts = cmd_text.split(None, 1)
|
|
428
|
+
if not parts:
|
|
429
|
+
return False, ""
|
|
430
|
+
|
|
431
|
+
cmd_name = parts[0].lower()
|
|
432
|
+
arg = parts[1].strip() if len(parts) > 1 else ""
|
|
433
|
+
|
|
434
|
+
# Check command match
|
|
435
|
+
if cmd_name in ("help", "?"):
|
|
436
|
+
help_text = (
|
|
437
|
+
"Available Slash Commands:\n"
|
|
438
|
+
" /model [name] Switch active model (Bankai-7B, Bankai-14B, Gemini, Claude, Local Ollama)\n"
|
|
439
|
+
" /persona [name] Switch active persona (RESEARCHER, ARCHITECT, CODER, CRITIC, DEBUGGER, AUTO)\n"
|
|
440
|
+
" /diff [mode] View surgical / git diff (inline or side-by-side)\n"
|
|
441
|
+
" /rollback [file] Roll back last uncommitted edit via Git (alias /undo)\n"
|
|
442
|
+
" /help Show this help message\n"
|
|
443
|
+
" /docs <query> Search DevDocs offline documentation index (alias /doc)\n"
|
|
444
|
+
" /clear Reset conversation history and context files\n"
|
|
445
|
+
" /test [file] Run ground-truth compiler and pytest verification\n"
|
|
446
|
+
" /add <file> Add file to active session context\n"
|
|
447
|
+
" /remove <file> Remove file from active session context\n"
|
|
448
|
+
" /undo Roll back last uncommitted edit via Git\n"
|
|
449
|
+
" /status Display active model, context files, tokens, and RAM\n"
|
|
450
|
+
" /map Display workspace AST symbol repository map\n"
|
|
451
|
+
" /exit, /quit Exit interactive session"
|
|
452
|
+
)
|
|
453
|
+
return True, help_text
|
|
454
|
+
|
|
455
|
+
elif cmd_name == "add":
|
|
456
|
+
if not arg:
|
|
457
|
+
return True, "Usage: /add <file_path>"
|
|
458
|
+
if self.add_file(arg):
|
|
459
|
+
return True, f"Added '{arg}' to active context."
|
|
460
|
+
return True, f"Error: File '{arg}' not found."
|
|
461
|
+
|
|
462
|
+
elif cmd_name in ("remove", "rm"):
|
|
463
|
+
if not arg:
|
|
464
|
+
return True, "Usage: /remove <file_path>"
|
|
465
|
+
if self.remove_file(arg):
|
|
466
|
+
return True, f"Removed '{arg}' from active context."
|
|
467
|
+
return True, f"Error: File '{arg}' is not in active context."
|
|
468
|
+
|
|
469
|
+
elif cmd_name in ("undo", "rollback"):
|
|
470
|
+
if cmd_name == "rollback" and arg:
|
|
471
|
+
if not self.git_guard.is_git_repo():
|
|
472
|
+
return True, "Not inside a Git repository; cannot rollback."
|
|
473
|
+
success = self.git_guard.rollback(files=[arg])
|
|
474
|
+
if success:
|
|
475
|
+
return True, f"Successfully rolled back uncommitted changes for '{arg}'."
|
|
476
|
+
return True, f"Rollback failed for '{arg}'."
|
|
477
|
+
success, msg = self.undo_last_edit()
|
|
478
|
+
return True, msg
|
|
479
|
+
|
|
480
|
+
elif cmd_name == "diff":
|
|
481
|
+
if not self.git_guard.is_git_repo():
|
|
482
|
+
return True, "Not inside a Git repository."
|
|
483
|
+
diff_text = self.git_guard.get_diff()
|
|
484
|
+
if not diff_text.strip():
|
|
485
|
+
return True, "Working tree is clean; no uncommitted changes."
|
|
486
|
+
return True, diff_text
|
|
487
|
+
|
|
488
|
+
elif cmd_name in ("clear", "cls"):
|
|
489
|
+
self.reset_context()
|
|
490
|
+
return True, "Session history and context files cleared."
|
|
491
|
+
|
|
492
|
+
elif cmd_name == "status":
|
|
493
|
+
st = self.get_status()
|
|
494
|
+
files_str = ", ".join(st["context_files"]) if st["context_files"] else "None"
|
|
495
|
+
status_lines = [
|
|
496
|
+
f"Active Model: {st['model']}",
|
|
497
|
+
f"Active Persona: {st.get('persona', 'AUTO')}",
|
|
498
|
+
f"Git Branch: {st.get('git_branch', 'no-git')}",
|
|
499
|
+
f"Context Files: {files_str}",
|
|
500
|
+
f"Conversation Turns: {st['turns']}",
|
|
501
|
+
f"Estimated Tokens: {st['token_count']} / {st['max_tokens']}",
|
|
502
|
+
f"Memory RSS: {st['ram_mb']:.2f} MB / 1024 MB",
|
|
503
|
+
f"Git Repository: {'Yes' if st['is_git_repo'] else 'No'}",
|
|
504
|
+
]
|
|
505
|
+
return True, "\n".join(status_lines)
|
|
506
|
+
|
|
507
|
+
elif cmd_name == "model":
|
|
508
|
+
if arg:
|
|
509
|
+
self.set_model(arg)
|
|
510
|
+
return True, f"Switched active model to '{arg}'."
|
|
511
|
+
return True, f"Active model: '{self.model_name}'"
|
|
512
|
+
|
|
513
|
+
elif cmd_name in ("persona", "role"):
|
|
514
|
+
if not arg or arg.lower() in ("list", "show", "help"):
|
|
515
|
+
if PersonaRegistry:
|
|
516
|
+
active_id = self.active_persona_profile.id if self.active_persona_profile else None
|
|
517
|
+
return True, PersonaRegistry.format_persona_table(active_id)
|
|
518
|
+
return True, f"Active persona: [{self.active_persona}]"
|
|
519
|
+
success, msg = self.set_persona(arg)
|
|
520
|
+
return True, msg
|
|
521
|
+
|
|
522
|
+
elif cmd_name in ("doc", "docs"):
|
|
523
|
+
if not arg:
|
|
524
|
+
return True, "Usage: /doc <query>" if cmd_name == "doc" else "Usage: /docs <query>"
|
|
525
|
+
if self.doc_retriever:
|
|
526
|
+
snippets = self.doc_retriever.format_context_snippets(arg, max_tokens=250)
|
|
527
|
+
if not snippets.strip():
|
|
528
|
+
return True, f"No documentation found for '{arg}'."
|
|
529
|
+
return True, snippets
|
|
530
|
+
return True, "DocRetriever is not available."
|
|
531
|
+
|
|
532
|
+
elif cmd_name in ("test", "verify"):
|
|
533
|
+
passed, out_msg = self.run_test(arg if arg else None)
|
|
534
|
+
return True, out_msg
|
|
535
|
+
|
|
536
|
+
elif cmd_name == "map":
|
|
537
|
+
if self.repo_map:
|
|
538
|
+
map_str = self.repo_map.get_repo_map(max_tokens=400, focus_files=self.get_context_files())
|
|
539
|
+
if not map_str.strip():
|
|
540
|
+
return True, "Repository map is empty."
|
|
541
|
+
return True, map_str
|
|
542
|
+
return True, "RepoMap is not available."
|
|
543
|
+
|
|
544
|
+
elif cmd_name in ("subagents", "spawn", "team"):
|
|
545
|
+
if not arg:
|
|
546
|
+
return True, "Usage: /spawn <prompt> (or /subagents <prompt>)"
|
|
547
|
+
try:
|
|
548
|
+
from k_cli.agents.subagents import execute_subagents
|
|
549
|
+
except ModuleNotFoundError:
|
|
550
|
+
from subagents import execute_subagents
|
|
551
|
+
res = execute_subagents(
|
|
552
|
+
prompt=arg,
|
|
553
|
+
context_files=self.get_context_files(),
|
|
554
|
+
driver=self.driver,
|
|
555
|
+
verifier=self.verifier,
|
|
556
|
+
workspace_dir=self.workspace_dir,
|
|
557
|
+
show_ui=True,
|
|
558
|
+
)
|
|
559
|
+
return True, f"Multi-Agent Run {'COMPLETED' if res.success else 'FAILED'}:\n{res.summary}"
|
|
560
|
+
|
|
561
|
+
elif cmd_name in ("exit", "quit", "q"):
|
|
562
|
+
return True, "EXIT"
|
|
563
|
+
|
|
564
|
+
elif is_slash:
|
|
565
|
+
return False, f"Unknown command '/{cmd_name}'. Type /help for available commands."
|
|
566
|
+
|
|
567
|
+
return False, "Not a slash command."
|
|
568
|
+
|
|
569
|
+
# --------------------------------------------------------------------------
|
|
570
|
+
# Multi-Turn Execution & Streaming Pipeline
|
|
571
|
+
# --------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
def process_turn(
|
|
574
|
+
self,
|
|
575
|
+
prompt: str,
|
|
576
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
577
|
+
) -> Generator[str, None, Dict[str, Any]]:
|
|
578
|
+
"""
|
|
579
|
+
Executes user prompt through full pipeline with streaming token output.
|
|
580
|
+
|
|
581
|
+
Pipeline steps:
|
|
582
|
+
1. Snapshot Git workspace checkpoint
|
|
583
|
+
2. Retrieve relevant DevDocs snippets (< 250 tokens)
|
|
584
|
+
3. Generate AST repository map (< 400 tokens)
|
|
585
|
+
4. Inject active context file contents
|
|
586
|
+
5. Call Orchestrator persona state machine with streaming
|
|
587
|
+
6. Parse SEARCH/REPLACE surgical patch blocks
|
|
588
|
+
7. Apply patches with AST verification
|
|
589
|
+
8. Commit on success or rollback on AST/test failure
|
|
590
|
+
9. Maintain rolling conversation token budget
|
|
591
|
+
|
|
592
|
+
Yields:
|
|
593
|
+
str: Generated tokens as they stream from LLM personas.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
Dict[str, Any]: Execution result summary.
|
|
597
|
+
"""
|
|
598
|
+
cleaned_prompt = (prompt or "").strip()
|
|
599
|
+
if not cleaned_prompt:
|
|
600
|
+
res_dict = {
|
|
601
|
+
"success": True,
|
|
602
|
+
"output": "",
|
|
603
|
+
"code": "",
|
|
604
|
+
"attempts": 1,
|
|
605
|
+
"ram_mb": self._get_current_ram_mb(),
|
|
606
|
+
"history_len": len(self.history),
|
|
607
|
+
}
|
|
608
|
+
self.last_result = res_dict
|
|
609
|
+
return res_dict
|
|
610
|
+
|
|
611
|
+
# 1. Snapshot Git workspace
|
|
612
|
+
if self.git_guard.is_git_repo():
|
|
613
|
+
self._last_snapshot = self.git_guard.create_snapshot()
|
|
614
|
+
|
|
615
|
+
# 2. Retrieve DevDocs context snippets
|
|
616
|
+
doc_snippets = ""
|
|
617
|
+
if self.doc_retriever:
|
|
618
|
+
try:
|
|
619
|
+
doc_snippets = self.doc_retriever.format_context_snippets(cleaned_prompt, max_tokens=250)
|
|
620
|
+
except Exception:
|
|
621
|
+
doc_snippets = ""
|
|
622
|
+
|
|
623
|
+
# 3. Generate AST repository map
|
|
624
|
+
repo_tree = ""
|
|
625
|
+
if self.repo_map:
|
|
626
|
+
try:
|
|
627
|
+
repo_tree = self.repo_map.get_repo_map(max_tokens=400, focus_files=self.get_context_files())
|
|
628
|
+
except Exception:
|
|
629
|
+
repo_tree = ""
|
|
630
|
+
|
|
631
|
+
# 4. Inject active context files
|
|
632
|
+
context_files_text = ""
|
|
633
|
+
for cf in self.context_files:
|
|
634
|
+
fp = self.workspace_dir / cf
|
|
635
|
+
if fp.exists() and fp.is_file():
|
|
636
|
+
try:
|
|
637
|
+
content = fp.read_text(encoding="utf-8")
|
|
638
|
+
context_files_text += f"\nFile: {cf}\n```\n{content}\n```\n"
|
|
639
|
+
except Exception:
|
|
640
|
+
pass
|
|
641
|
+
|
|
642
|
+
# 5. Formulate enriched prompt
|
|
643
|
+
prompt_sections = []
|
|
644
|
+
if doc_snippets.strip():
|
|
645
|
+
prompt_sections.append(f"DevDocs Reference Snippets:\n{doc_snippets.strip()}")
|
|
646
|
+
if repo_tree.strip():
|
|
647
|
+
prompt_sections.append(f"Workspace Repository Map:\n{repo_tree.strip()}")
|
|
648
|
+
if context_files_text.strip():
|
|
649
|
+
prompt_sections.append(f"Active Context Files:\n{context_files_text.strip()}")
|
|
650
|
+
|
|
651
|
+
if self.history:
|
|
652
|
+
history_lines = ["Recent Conversation History:"]
|
|
653
|
+
for i, h in enumerate(self.history[-3:], 1):
|
|
654
|
+
p_snip = h.get("prompt", "")[:120].replace("\n", " ")
|
|
655
|
+
r_snip = h.get("response", "")[:120].replace("\n", " ")
|
|
656
|
+
history_lines.append(f"User [{i}]: {p_snip}")
|
|
657
|
+
history_lines.append(f"Assistant [{i}]: {r_snip}")
|
|
658
|
+
prompt_sections.append("\n".join(history_lines))
|
|
659
|
+
|
|
660
|
+
prompt_sections.append(f"User Task Request:\n{cleaned_prompt}")
|
|
661
|
+
augmented_prompt = "\n\n".join(prompt_sections)
|
|
662
|
+
|
|
663
|
+
# 6. Stream tokens from Orchestrator via worker thread
|
|
664
|
+
token_q: queue.Queue = queue.Queue()
|
|
665
|
+
pipeline_res: List[OrchestratorResult] = []
|
|
666
|
+
pipeline_err: List[Exception] = []
|
|
667
|
+
|
|
668
|
+
def _worker():
|
|
669
|
+
try:
|
|
670
|
+
def persona_cb(persona, tok: str):
|
|
671
|
+
token_q.put((persona, tok))
|
|
672
|
+
if stream_callback:
|
|
673
|
+
stream_callback(tok)
|
|
674
|
+
|
|
675
|
+
orch_res = self.orchestrator.execute_pipeline(
|
|
676
|
+
user_prompt=augmented_prompt,
|
|
677
|
+
language="python",
|
|
678
|
+
token_stream_callback=persona_cb,
|
|
679
|
+
persona=self.active_persona_profile,
|
|
680
|
+
)
|
|
681
|
+
pipeline_res.append(orch_res)
|
|
682
|
+
except Exception as exc:
|
|
683
|
+
pipeline_err.append(exc)
|
|
684
|
+
finally:
|
|
685
|
+
token_q.put(None)
|
|
686
|
+
|
|
687
|
+
worker_thread = threading.Thread(target=_worker, daemon=True)
|
|
688
|
+
worker_thread.start()
|
|
689
|
+
|
|
690
|
+
while True:
|
|
691
|
+
item = token_q.get()
|
|
692
|
+
if item is None:
|
|
693
|
+
break
|
|
694
|
+
_persona, token = item
|
|
695
|
+
yield token
|
|
696
|
+
|
|
697
|
+
worker_thread.join()
|
|
698
|
+
|
|
699
|
+
if pipeline_err:
|
|
700
|
+
final_code = ""
|
|
701
|
+
success = False
|
|
702
|
+
attempts = 1
|
|
703
|
+
ram_mb = self._get_current_ram_mb()
|
|
704
|
+
elif pipeline_res:
|
|
705
|
+
res = pipeline_res[0]
|
|
706
|
+
final_code = res.final_code
|
|
707
|
+
success = res.success
|
|
708
|
+
attempts = res.attempts
|
|
709
|
+
ram_mb = res.ram_usage_mb
|
|
710
|
+
else:
|
|
711
|
+
final_code = ""
|
|
712
|
+
success = False
|
|
713
|
+
attempts = 1
|
|
714
|
+
ram_mb = self._get_current_ram_mb()
|
|
715
|
+
|
|
716
|
+
# 7. Check for SEARCH/REPLACE blocks and apply patches
|
|
717
|
+
patch_blocks = self.patcher.parse_search_replace_blocks(final_code)
|
|
718
|
+
patches_applied = False
|
|
719
|
+
patch_error = ""
|
|
720
|
+
|
|
721
|
+
if patch_blocks:
|
|
722
|
+
# Apply to tracked context files
|
|
723
|
+
target_files = [self.workspace_dir / f for f in self.context_files] if self.context_files else []
|
|
724
|
+
if not target_files:
|
|
725
|
+
# Find python files in workspace to test patch against
|
|
726
|
+
target_files = list(self.workspace_dir.glob("*.py"))
|
|
727
|
+
|
|
728
|
+
any_patch_succeeded = False
|
|
729
|
+
for tf in target_files:
|
|
730
|
+
if tf.is_file():
|
|
731
|
+
p_ok, p_err = self.patcher.apply_file_patches(str(tf), final_code, validate_ast=True)
|
|
732
|
+
if p_ok:
|
|
733
|
+
any_patch_succeeded = True
|
|
734
|
+
else:
|
|
735
|
+
patch_error = p_err
|
|
736
|
+
|
|
737
|
+
if any_patch_succeeded:
|
|
738
|
+
patches_applied = True
|
|
739
|
+
success = True
|
|
740
|
+
if self.git_guard.is_git_repo():
|
|
741
|
+
self.git_guard.commit_success(
|
|
742
|
+
f"feat: apply surgical patch for '{cleaned_prompt[:40]}'",
|
|
743
|
+
files=[str(tf.relative_to(self.workspace_dir)) for tf in target_files if tf.is_file()],
|
|
744
|
+
)
|
|
745
|
+
else:
|
|
746
|
+
# Rollback on patch failure
|
|
747
|
+
if self.git_guard.is_git_repo():
|
|
748
|
+
self.git_guard.rollback()
|
|
749
|
+
success = False
|
|
750
|
+
|
|
751
|
+
# 8. Record history and enforce rolling token budget
|
|
752
|
+
self.history.append({
|
|
753
|
+
"prompt": cleaned_prompt,
|
|
754
|
+
"response": final_code,
|
|
755
|
+
"code": final_code,
|
|
756
|
+
"success": success,
|
|
757
|
+
"attempts": attempts,
|
|
758
|
+
"patches_applied": patches_applied,
|
|
759
|
+
})
|
|
760
|
+
self._prune_history_if_needed()
|
|
761
|
+
|
|
762
|
+
# Auto-persist session state to ~/.kcli/sessions/
|
|
763
|
+
try:
|
|
764
|
+
from k_cli.core.storage_manager import LocalStorageManager
|
|
765
|
+
if not hasattr(self, "_session_id") or not self._session_id:
|
|
766
|
+
self._session_id = f"session_{int(time.time())}"
|
|
767
|
+
LocalStorageManager.save_session(
|
|
768
|
+
session_id=self._session_id,
|
|
769
|
+
workspace_dir=str(self.workspace_dir),
|
|
770
|
+
active_model=self.model_name,
|
|
771
|
+
active_persona=self.active_persona,
|
|
772
|
+
context_files=self.context_files,
|
|
773
|
+
history=self.history,
|
|
774
|
+
git_branch=self.git_guard.get_current_branch() if hasattr(self.git_guard, "get_current_branch") else "main",
|
|
775
|
+
)
|
|
776
|
+
except Exception:
|
|
777
|
+
pass
|
|
778
|
+
|
|
779
|
+
res_dict = {
|
|
780
|
+
"success": success,
|
|
781
|
+
"output": final_code,
|
|
782
|
+
"code": final_code,
|
|
783
|
+
"attempts": attempts,
|
|
784
|
+
"patches_applied": patches_applied,
|
|
785
|
+
"patch_error": patch_error,
|
|
786
|
+
"ram_mb": ram_mb,
|
|
787
|
+
"history_len": len(self.history),
|
|
788
|
+
}
|
|
789
|
+
self.last_result = res_dict
|
|
790
|
+
return res_dict
|
|
791
|
+
|
|
792
|
+
@classmethod
|
|
793
|
+
def load_latest(cls, workspace_dir: str = ".", mock_mode: bool = False) -> Optional[SessionManager]:
|
|
794
|
+
"""Restores the latest saved session from ~/.kcli/sessions/latest_session.json."""
|
|
795
|
+
try:
|
|
796
|
+
from k_cli.core.storage_manager import LocalStorageManager
|
|
797
|
+
checkpoint = LocalStorageManager.load_latest_session()
|
|
798
|
+
if not checkpoint:
|
|
799
|
+
return None
|
|
800
|
+
session = cls(
|
|
801
|
+
workspace_dir=checkpoint.workspace_dir or workspace_dir,
|
|
802
|
+
model_name=checkpoint.active_model,
|
|
803
|
+
mock_mode=mock_mode,
|
|
804
|
+
)
|
|
805
|
+
session._session_id = checkpoint.session_id
|
|
806
|
+
session.context_files = list(checkpoint.context_files)
|
|
807
|
+
session.history = list(checkpoint.history)
|
|
808
|
+
if checkpoint.active_persona:
|
|
809
|
+
session.set_persona(checkpoint.active_persona)
|
|
810
|
+
return session
|
|
811
|
+
except Exception:
|
|
812
|
+
return None
|
|
813
|
+
|
|
814
|
+
def execute_turn(
|
|
815
|
+
self,
|
|
816
|
+
prompt: str,
|
|
817
|
+
stream_callback: Optional[Callable[[str], None]] = None,
|
|
818
|
+
) -> Dict[str, Any]:
|
|
819
|
+
"""
|
|
820
|
+
Synchronous convenience wrapper to execute a turn and return the result dictionary.
|
|
821
|
+
"""
|
|
822
|
+
gen = self.process_turn(prompt, stream_callback=stream_callback)
|
|
823
|
+
# Drain generator to completion
|
|
824
|
+
for _ in gen:
|
|
825
|
+
pass
|
|
826
|
+
return self.last_result or {}
|