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.
Files changed (75) hide show
  1. k_cli/__init__.py +77 -0
  2. k_cli/agents/__init__.py +0 -0
  3. k_cli/agents/adversarial_swarm.py +338 -0
  4. k_cli/agents/agent_core.py +255 -0
  5. k_cli/agents/background_daemon.py +141 -0
  6. k_cli/agents/orchestrator.py +376 -0
  7. k_cli/agents/persona.py +649 -0
  8. k_cli/agents/scaffold_engine.py +121 -0
  9. k_cli/agents/strands_agent.py +832 -0
  10. k_cli/agents/subagents.py +1496 -0
  11. k_cli/cli.py +3297 -0
  12. k_cli/core/__init__.py +0 -0
  13. k_cli/core/airgap.py +95 -0
  14. k_cli/core/credentials.py +548 -0
  15. k_cli/core/intent_sensor.py +177 -0
  16. k_cli/core/llm_driver.py +1028 -0
  17. k_cli/core/model_manager.py +1109 -0
  18. k_cli/core/models_hub.py +913 -0
  19. k_cli/core/prompting.py +41 -0
  20. k_cli/core/sdk.py +322 -0
  21. k_cli/core/session.py +826 -0
  22. k_cli/core/smart_router.py +230 -0
  23. k_cli/core/storage_manager.py +176 -0
  24. k_cli/core/viewport_engine.py +117 -0
  25. k_cli/demo/demo_runner.py +579 -0
  26. k_cli/git/__init__.py +0 -0
  27. k_cli/git/ai_bisect.py +208 -0
  28. k_cli/git/conflict_resolver.py +1039 -0
  29. k_cli/git/git_guard.py +417 -0
  30. k_cli/git/patcher.py +1175 -0
  31. k_cli/git/repo_map.py +1780 -0
  32. k_cli/git/smart_git.py +928 -0
  33. k_cli/git/verifier.py +969 -0
  34. k_cli/github/__init__.py +0 -0
  35. k_cli/github/dedup_engine.py +787 -0
  36. k_cli/github/github_client.py +1702 -0
  37. k_cli/github/github_engine.py +641 -0
  38. k_cli/github/local_hub.py +209 -0
  39. k_cli/github/pr_watcher.py +129 -0
  40. k_cli/github/trending.py +205 -0
  41. k_cli/tools/__init__.py +0 -0
  42. k_cli/tools/audit.py +79 -0
  43. k_cli/tools/chaos_immunity.py +377 -0
  44. k_cli/tools/codebase_qa.py +106 -0
  45. k_cli/tools/command_runner.py +256 -0
  46. k_cli/tools/diagram_generator.py +547 -0
  47. k_cli/tools/doc_retriever.py +1332 -0
  48. k_cli/tools/feature.py +105 -0
  49. k_cli/tools/ghost_daemon.py +122 -0
  50. k_cli/tools/incident_triage.py +1365 -0
  51. k_cli/tools/mcp_client.py +1846 -0
  52. k_cli/tools/repo_gardener.py +142 -0
  53. k_cli/tools/rules.py +109 -0
  54. k_cli/tools/security.py +52 -0
  55. k_cli/tools/security_healer.py +999 -0
  56. k_cli/tools/synapse_graph.py +155 -0
  57. k_cli/tui/__init__.py +0 -0
  58. k_cli/tui/diff_viewer.py +223 -0
  59. k_cli/tui/tui.py +1145 -0
  60. k_cli/tui/tui_animations.py +648 -0
  61. k_cli/tui/tui_app.py +2788 -0
  62. k_cli/ui/__init__.py +10 -0
  63. k_cli/ui/simple_repl.py +315 -0
  64. k_cli/web/__init__.py +7 -0
  65. k_cli/web/server.py +624 -0
  66. k_cli/web/static/app.js +830 -0
  67. k_cli/web/static/index.html +495 -0
  68. k_cli/web/static/monitor.html +189 -0
  69. k_cli/web/static/style.css +838 -0
  70. k_cli_for_devs-1.0.0.dist-info/METADATA +461 -0
  71. k_cli_for_devs-1.0.0.dist-info/RECORD +75 -0
  72. k_cli_for_devs-1.0.0.dist-info/WHEEL +5 -0
  73. k_cli_for_devs-1.0.0.dist-info/entry_points.txt +2 -0
  74. k_cli_for_devs-1.0.0.dist-info/licenses/LICENSE +21 -0
  75. 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 {}