npcsh 1.1.14__py3-none-any.whl → 1.1.16__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 (168) hide show
  1. npcsh/_state.py +533 -80
  2. npcsh/mcp_server.py +2 -1
  3. npcsh/npc.py +84 -32
  4. npcsh/npc_team/alicanto.npc +22 -1
  5. npcsh/npc_team/corca.npc +28 -9
  6. npcsh/npc_team/frederic.npc +25 -4
  7. npcsh/npc_team/guac.npc +22 -0
  8. npcsh/npc_team/jinxs/bin/nql.jinx +141 -0
  9. npcsh/npc_team/jinxs/bin/sync.jinx +230 -0
  10. {npcsh-1.1.14.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/bin}/vixynt.jinx +8 -30
  11. npcsh/npc_team/jinxs/bin/wander.jinx +152 -0
  12. npcsh/npc_team/jinxs/lib/browser/browser_action.jinx +220 -0
  13. npcsh/npc_team/jinxs/lib/browser/browser_screenshot.jinx +40 -0
  14. npcsh/npc_team/jinxs/lib/browser/close_browser.jinx +14 -0
  15. npcsh/npc_team/jinxs/lib/browser/open_browser.jinx +43 -0
  16. npcsh/npc_team/jinxs/lib/computer_use/click.jinx +23 -0
  17. npcsh/npc_team/jinxs/lib/computer_use/key_press.jinx +26 -0
  18. npcsh/npc_team/jinxs/lib/computer_use/launch_app.jinx +37 -0
  19. npcsh/npc_team/jinxs/lib/computer_use/screenshot.jinx +23 -0
  20. npcsh/npc_team/jinxs/lib/computer_use/type_text.jinx +27 -0
  21. npcsh/npc_team/jinxs/lib/computer_use/wait.jinx +21 -0
  22. {npcsh-1.1.14.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/lib/core}/edit_file.jinx +3 -3
  23. {npcsh-1.1.14.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/lib/core}/load_file.jinx +1 -1
  24. npcsh/npc_team/jinxs/lib/core/paste.jinx +134 -0
  25. {npcsh-1.1.14.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/lib/core}/search.jinx +2 -1
  26. npcsh/npc_team/jinxs/{code → lib/core}/sh.jinx +2 -8
  27. npcsh/npc_team/jinxs/{code → lib/core}/sql.jinx +1 -1
  28. npcsh/npc_team/jinxs/lib/orchestration/convene.jinx +232 -0
  29. npcsh/npc_team/jinxs/lib/orchestration/delegate.jinx +184 -0
  30. npcsh/npc_team/jinxs/lib/research/arxiv.jinx +76 -0
  31. npcsh/npc_team/jinxs/lib/research/paper_search.jinx +101 -0
  32. npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +69 -0
  33. npcsh/npc_team/jinxs/{utils/core → lib/utils}/build.jinx +8 -8
  34. npcsh/npc_team/jinxs/lib/utils/jinxs.jinx +176 -0
  35. npcsh/npc_team/jinxs/lib/utils/shh.jinx +17 -0
  36. npcsh/npc_team/jinxs/lib/utils/switch.jinx +62 -0
  37. npcsh/npc_team/jinxs/lib/utils/switches.jinx +61 -0
  38. npcsh/npc_team/jinxs/lib/utils/teamviz.jinx +205 -0
  39. npcsh/npc_team/jinxs/lib/utils/verbose.jinx +17 -0
  40. npcsh/npc_team/kadiefa.npc +19 -1
  41. npcsh/npc_team/plonk.npc +26 -1
  42. npcsh/npc_team/plonkjr.npc +22 -1
  43. npcsh/npc_team/sibiji.npc +23 -2
  44. npcsh/npcsh.py +153 -39
  45. npcsh/ui.py +22 -1
  46. npcsh-1.1.16.data/data/npcsh/npc_team/alicanto.npc +23 -0
  47. npcsh-1.1.16.data/data/npcsh/npc_team/arxiv.jinx +76 -0
  48. npcsh-1.1.16.data/data/npcsh/npc_team/browser_action.jinx +220 -0
  49. npcsh-1.1.16.data/data/npcsh/npc_team/browser_screenshot.jinx +40 -0
  50. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/build.jinx +8 -8
  51. npcsh-1.1.16.data/data/npcsh/npc_team/click.jinx +23 -0
  52. npcsh-1.1.16.data/data/npcsh/npc_team/close_browser.jinx +14 -0
  53. npcsh-1.1.16.data/data/npcsh/npc_team/convene.jinx +232 -0
  54. npcsh-1.1.16.data/data/npcsh/npc_team/corca.npc +31 -0
  55. npcsh-1.1.16.data/data/npcsh/npc_team/delegate.jinx +184 -0
  56. {npcsh/npc_team/jinxs/utils → npcsh-1.1.16.data/data/npcsh/npc_team}/edit_file.jinx +3 -3
  57. npcsh-1.1.16.data/data/npcsh/npc_team/frederic.npc +27 -0
  58. npcsh-1.1.16.data/data/npcsh/npc_team/guac.npc +22 -0
  59. npcsh-1.1.16.data/data/npcsh/npc_team/jinxs.jinx +176 -0
  60. npcsh-1.1.16.data/data/npcsh/npc_team/kadiefa.npc +21 -0
  61. npcsh-1.1.16.data/data/npcsh/npc_team/key_press.jinx +26 -0
  62. npcsh-1.1.16.data/data/npcsh/npc_team/launch_app.jinx +37 -0
  63. {npcsh/npc_team/jinxs/utils → npcsh-1.1.16.data/data/npcsh/npc_team}/load_file.jinx +1 -1
  64. npcsh-1.1.16.data/data/npcsh/npc_team/nql.jinx +141 -0
  65. npcsh-1.1.16.data/data/npcsh/npc_team/open_browser.jinx +43 -0
  66. npcsh-1.1.16.data/data/npcsh/npc_team/paper_search.jinx +101 -0
  67. npcsh-1.1.16.data/data/npcsh/npc_team/paste.jinx +134 -0
  68. npcsh-1.1.16.data/data/npcsh/npc_team/plonk.npc +27 -0
  69. npcsh-1.1.16.data/data/npcsh/npc_team/plonkjr.npc +23 -0
  70. npcsh-1.1.16.data/data/npcsh/npc_team/screenshot.jinx +23 -0
  71. {npcsh/npc_team/jinxs/utils → npcsh-1.1.16.data/data/npcsh/npc_team}/search.jinx +2 -1
  72. npcsh-1.1.16.data/data/npcsh/npc_team/semantic_scholar.jinx +69 -0
  73. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/sh.jinx +2 -8
  74. npcsh-1.1.16.data/data/npcsh/npc_team/shh.jinx +17 -0
  75. npcsh-1.1.16.data/data/npcsh/npc_team/sibiji.npc +24 -0
  76. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/sql.jinx +1 -1
  77. npcsh-1.1.16.data/data/npcsh/npc_team/switch.jinx +62 -0
  78. npcsh-1.1.16.data/data/npcsh/npc_team/switches.jinx +61 -0
  79. npcsh-1.1.16.data/data/npcsh/npc_team/sync.jinx +230 -0
  80. npcsh-1.1.16.data/data/npcsh/npc_team/teamviz.jinx +205 -0
  81. npcsh-1.1.16.data/data/npcsh/npc_team/type_text.jinx +27 -0
  82. npcsh-1.1.16.data/data/npcsh/npc_team/verbose.jinx +17 -0
  83. {npcsh/npc_team/jinxs/utils → npcsh-1.1.16.data/data/npcsh/npc_team}/vixynt.jinx +8 -30
  84. npcsh-1.1.16.data/data/npcsh/npc_team/wait.jinx +21 -0
  85. npcsh-1.1.16.data/data/npcsh/npc_team/wander.jinx +152 -0
  86. {npcsh-1.1.14.dist-info → npcsh-1.1.16.dist-info}/METADATA +399 -58
  87. npcsh-1.1.16.dist-info/RECORD +170 -0
  88. npcsh-1.1.16.dist-info/entry_points.txt +19 -0
  89. npcsh-1.1.16.dist-info/top_level.txt +2 -0
  90. project/__init__.py +1 -0
  91. npcsh/npc_team/foreman.npc +0 -7
  92. npcsh/npc_team/jinxs/modes/alicanto.jinx +0 -194
  93. npcsh/npc_team/jinxs/modes/corca.jinx +0 -249
  94. npcsh/npc_team/jinxs/modes/guac.jinx +0 -317
  95. npcsh/npc_team/jinxs/modes/plonk.jinx +0 -214
  96. npcsh/npc_team/jinxs/modes/pti.jinx +0 -170
  97. npcsh/npc_team/jinxs/modes/wander.jinx +0 -186
  98. npcsh/npc_team/jinxs/utils/agent.jinx +0 -17
  99. npcsh/npc_team/jinxs/utils/core/jinxs.jinx +0 -32
  100. npcsh-1.1.14.data/data/npcsh/npc_team/agent.jinx +0 -17
  101. npcsh-1.1.14.data/data/npcsh/npc_team/alicanto.jinx +0 -194
  102. npcsh-1.1.14.data/data/npcsh/npc_team/alicanto.npc +0 -2
  103. npcsh-1.1.14.data/data/npcsh/npc_team/corca.jinx +0 -249
  104. npcsh-1.1.14.data/data/npcsh/npc_team/corca.npc +0 -12
  105. npcsh-1.1.14.data/data/npcsh/npc_team/foreman.npc +0 -7
  106. npcsh-1.1.14.data/data/npcsh/npc_team/frederic.npc +0 -6
  107. npcsh-1.1.14.data/data/npcsh/npc_team/guac.jinx +0 -317
  108. npcsh-1.1.14.data/data/npcsh/npc_team/jinxs.jinx +0 -32
  109. npcsh-1.1.14.data/data/npcsh/npc_team/kadiefa.npc +0 -3
  110. npcsh-1.1.14.data/data/npcsh/npc_team/plonk.jinx +0 -214
  111. npcsh-1.1.14.data/data/npcsh/npc_team/plonk.npc +0 -2
  112. npcsh-1.1.14.data/data/npcsh/npc_team/plonkjr.npc +0 -2
  113. npcsh-1.1.14.data/data/npcsh/npc_team/pti.jinx +0 -170
  114. npcsh-1.1.14.data/data/npcsh/npc_team/sibiji.npc +0 -3
  115. npcsh-1.1.14.data/data/npcsh/npc_team/wander.jinx +0 -186
  116. npcsh-1.1.14.dist-info/RECORD +0 -135
  117. npcsh-1.1.14.dist-info/entry_points.txt +0 -9
  118. npcsh-1.1.14.dist-info/top_level.txt +0 -1
  119. /npcsh/npc_team/jinxs/{utils → bin}/roll.jinx +0 -0
  120. /npcsh/npc_team/jinxs/{utils → bin}/sample.jinx +0 -0
  121. /npcsh/npc_team/jinxs/{modes → bin}/spool.jinx +0 -0
  122. /npcsh/npc_team/jinxs/{modes → bin}/yap.jinx +0 -0
  123. /npcsh/npc_team/jinxs/{utils → lib/computer_use}/trigger.jinx +0 -0
  124. /npcsh/npc_team/jinxs/{utils → lib/core}/chat.jinx +0 -0
  125. /npcsh/npc_team/jinxs/{utils → lib/core}/cmd.jinx +0 -0
  126. /npcsh/npc_team/jinxs/{utils → lib/core}/compress.jinx +0 -0
  127. /npcsh/npc_team/jinxs/{utils → lib/core}/ots.jinx +0 -0
  128. /npcsh/npc_team/jinxs/{code → lib/core}/python.jinx +0 -0
  129. /npcsh/npc_team/jinxs/{utils → lib/core}/sleep.jinx +0 -0
  130. /npcsh/npc_team/jinxs/{utils/core → lib/utils}/compile.jinx +0 -0
  131. /npcsh/npc_team/jinxs/{utils/core → lib/utils}/help.jinx +0 -0
  132. /npcsh/npc_team/jinxs/{utils/core → lib/utils}/init.jinx +0 -0
  133. /npcsh/npc_team/jinxs/{utils → lib/utils}/serve.jinx +0 -0
  134. /npcsh/npc_team/jinxs/{utils/core → lib/utils}/set.jinx +0 -0
  135. /npcsh/npc_team/jinxs/{utils → lib/utils}/usage.jinx +0 -0
  136. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/alicanto.png +0 -0
  137. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/chat.jinx +0 -0
  138. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  139. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/compile.jinx +0 -0
  140. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/compress.jinx +0 -0
  141. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/corca.png +0 -0
  142. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/corca_example.png +0 -0
  143. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/frederic4.png +0 -0
  144. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/guac.png +0 -0
  145. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/help.jinx +0 -0
  146. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/init.jinx +0 -0
  147. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  148. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/npc-studio.jinx +0 -0
  149. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  150. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  151. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/ots.jinx +0 -0
  152. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/plonk.png +0 -0
  153. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  154. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/python.jinx +0 -0
  155. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/roll.jinx +0 -0
  156. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/sample.jinx +0 -0
  157. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/serve.jinx +0 -0
  158. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/set.jinx +0 -0
  159. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/sibiji.png +0 -0
  160. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  161. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/spool.jinx +0 -0
  162. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/spool.png +0 -0
  163. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  164. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/usage.jinx +0 -0
  165. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/yap.jinx +0 -0
  166. {npcsh-1.1.14.data → npcsh-1.1.16.data}/data/npcsh/npc_team/yap.png +0 -0
  167. {npcsh-1.1.14.dist-info → npcsh-1.1.16.dist-info}/WHEEL +0 -0
  168. {npcsh-1.1.14.dist-info → npcsh-1.1.16.dist-info}/licenses/LICENSE +0 -0
npcsh/_state.py CHANGED
@@ -21,20 +21,19 @@ import textwrap
21
21
  from typing import Dict, List, Any, Tuple, Union, Optional, Callable
22
22
  import yaml
23
23
 
24
- # Setup debug logging if NPCSH_DEBUG is set
25
- def _setup_debug_logging():
26
- if os.environ.get("NPCSH_DEBUG", "0") == "1":
27
- logging.basicConfig(
28
- level=logging.DEBUG,
29
- format='%(asctime)s [%(name)s] %(levelname)s: %(message)s',
30
- datefmt='%H:%M:%S'
31
- )
32
- # Set specific loggers to DEBUG
33
- logging.getLogger("npcsh.state").setLevel(logging.DEBUG)
34
- logging.getLogger("npcpy.llm_funcs").setLevel(logging.DEBUG)
35
- logging.getLogger("npcsh.state").debug("Debug logging enabled via NPCSH_DEBUG=1")
24
+ # Setup logging - INFO by default, DEBUG if NPCSH_DEBUG=1
25
+ def _setup_logging():
26
+ level = logging.DEBUG if os.environ.get("NPCSH_DEBUG", "0") == "1" else logging.INFO
27
+ logging.basicConfig(
28
+ level=level,
29
+ format='%(message)s', # Simple format - just the message
30
+ datefmt='%H:%M:%S'
31
+ )
32
+ # Always show tool calls from llm_funcs at INFO level
33
+ logging.getLogger("npcpy.llm_funcs").setLevel(level)
34
+ logging.getLogger("npcsh.state").setLevel(level)
36
35
 
37
- _setup_debug_logging()
36
+ _setup_logging()
38
37
 
39
38
  # Platform-specific imports
40
39
  try:
@@ -167,6 +166,10 @@ class ShellState:
167
166
  session_input_tokens: int = 0
168
167
  session_output_tokens: int = 0
169
168
  session_cost_usd: float = 0.0
169
+ # Session timing
170
+ session_start_time: float = field(default_factory=lambda: __import__('time').time())
171
+ # Logging level: "silent", "normal", "verbose"
172
+ log_level: str = "normal"
170
173
 
171
174
  def get_model_for_command(self, model_type: str = "chat"):
172
175
  if model_type == "chat":
@@ -182,7 +185,34 @@ class ShellState:
182
185
  elif model_type == "video_gen":
183
186
  return self.video_gen_model, self.video_gen_provider
184
187
  else:
185
- return self.chat_model, self.chat_provider
188
+ return self.chat_model, self.chat_provider
189
+
190
+ def set_log_level(self, level: str) -> str:
191
+ """Set the logging level and configure npcpy loggers accordingly."""
192
+ level = level.lower()
193
+ if level not in ("silent", "normal", "verbose"):
194
+ return f"Invalid log level: {level}. Use 'silent', 'normal', or 'verbose'."
195
+
196
+ self.log_level = level
197
+
198
+ # Map to Python logging levels
199
+ level_map = {
200
+ "silent": logging.WARNING,
201
+ "normal": logging.INFO,
202
+ "verbose": logging.DEBUG,
203
+ }
204
+ log_level = level_map[level]
205
+
206
+ # Configure npcpy loggers
207
+ for logger_name in ["npcpy", "npcpy.gen", "npcpy.gen.response", "npcsh"]:
208
+ logger = logging.getLogger(logger_name)
209
+ logger.setLevel(log_level)
210
+
211
+ # Also set root logger for npcpy
212
+ logging.getLogger("npcpy").setLevel(log_level)
213
+
214
+ return f"Log level set to: {level}"
215
+
186
216
  CONFIG_KEY_MAP = {
187
217
 
188
218
  "model": "NPCSH_CHAT_MODEL",
@@ -338,10 +368,27 @@ def initialize_base_npcs_if_needed(db_path: str) -> None:
338
368
  """
339
369
  )
340
370
 
341
- # Package directories
342
- package_dir = os.path.dirname(__file__)
371
+ # Package directories - use helper that handles PyInstaller bundles
372
+ package_dir = get_package_dir()
343
373
  package_npc_team_dir = os.path.join(package_dir, "npc_team")
344
374
 
375
+ # Debug logging for package path resolution
376
+ if os.environ.get("NPCSH_DEBUG", "0") == "1":
377
+ print(f"[DEBUG] Package dir: {package_dir}")
378
+ print(f"[DEBUG] Package npc_team dir: {package_npc_team_dir}")
379
+ print(f"[DEBUG] npc_team exists: {os.path.exists(package_npc_team_dir)}")
380
+ if os.path.exists(package_npc_team_dir):
381
+ print(f"[DEBUG] npc_team contents: {os.listdir(package_npc_team_dir)}")
382
+
383
+ if not os.path.exists(package_npc_team_dir):
384
+ print(f"Warning: Package npc_team directory not found at {package_npc_team_dir}")
385
+ # For bundled executables, try to find it
386
+ if getattr(sys, 'frozen', False):
387
+ print(f"Running as frozen executable, _MEIPASS: {getattr(sys, '_MEIPASS', 'N/A')}")
388
+ if hasattr(sys, '_MEIPASS'):
389
+ print(f"Contents of _MEIPASS: {os.listdir(sys._MEIPASS)}")
390
+ return
391
+
345
392
  user_npc_team_dir = os.path.expanduser("~/.npcsh/npc_team")
346
393
 
347
394
  user_jinxs_dir = os.path.join(user_npc_team_dir, "jinxs")
@@ -369,31 +416,76 @@ def initialize_base_npcs_if_needed(db_path: str) -> None:
369
416
  shutil.copy2(source_path, destination_path)
370
417
  print(f"Copied ctx {filename} to {destination_path}")
371
418
 
372
- # Copy jinxs directory RECURSIVELY
419
+ # Copy jinxs directory RECURSIVELY with manifest tracking
420
+ # This ensures we only sync package jinxs and can clean up old ones
373
421
  package_jinxs_dir = os.path.join(package_npc_team_dir, "jinxs")
422
+ manifest_path = os.path.join(user_jinxs_dir, ".package_manifest.json")
423
+
424
+ # Load existing manifest of package-synced jinxs
425
+ old_package_jinxs = set()
426
+ if os.path.exists(manifest_path):
427
+ try:
428
+ import json
429
+ with open(manifest_path, 'r') as f:
430
+ old_package_jinxs = set(json.load(f).get('jinxs', []))
431
+ except:
432
+ pass
433
+
434
+ # Track current package jinxs
435
+ current_package_jinxs = set()
436
+
374
437
  if os.path.exists(package_jinxs_dir):
375
438
  for root, dirs, files in os.walk(package_jinxs_dir):
376
439
  # Calculate relative path from package_jinxs_dir
377
440
  rel_path = os.path.relpath(root, package_jinxs_dir)
378
-
441
+
379
442
  # Create corresponding directory in user jinxs
380
443
  if rel_path == '.':
381
444
  dest_dir = user_jinxs_dir
382
445
  else:
383
446
  dest_dir = os.path.join(user_jinxs_dir, rel_path)
384
447
  os.makedirs(dest_dir, exist_ok=True)
385
-
448
+
386
449
  # Copy all .jinx files in this directory
387
450
  for filename in files:
388
451
  if filename.endswith(".jinx"):
389
452
  source_jinx_path = os.path.join(root, filename)
390
453
  destination_jinx_path = os.path.join(dest_dir, filename)
391
-
454
+ jinx_rel_path = os.path.join(rel_path, filename) if rel_path != '.' else filename
455
+ current_package_jinxs.add(jinx_rel_path)
456
+
392
457
  if not os.path.exists(destination_jinx_path) or file_has_changed(
393
458
  source_jinx_path, destination_jinx_path
394
459
  ):
395
460
  shutil.copy2(source_jinx_path, destination_jinx_path)
396
- print(f"Copied jinx {os.path.join(rel_path, filename)} to {destination_jinx_path}")
461
+ print(f"Copied jinx {jinx_rel_path} to {destination_jinx_path}")
462
+
463
+ # Clean up old package jinxs that are no longer in the package
464
+ # (but preserve user-created jinxs that were never in the manifest)
465
+ stale_jinxs = old_package_jinxs - current_package_jinxs
466
+ for stale_jinx in stale_jinxs:
467
+ stale_path = os.path.join(user_jinxs_dir, stale_jinx)
468
+ if os.path.exists(stale_path):
469
+ try:
470
+ os.remove(stale_path)
471
+ print(f"Removed stale package jinx: {stale_jinx}")
472
+ # Remove empty parent directories
473
+ parent_dir = os.path.dirname(stale_path)
474
+ while parent_dir != user_jinxs_dir:
475
+ if os.path.isdir(parent_dir) and not os.listdir(parent_dir):
476
+ os.rmdir(parent_dir)
477
+ print(f"Removed empty directory: {parent_dir}")
478
+ parent_dir = os.path.dirname(parent_dir)
479
+ except Exception as e:
480
+ print(f"Could not remove stale jinx {stale_jinx}: {e}")
481
+
482
+ # Save updated manifest
483
+ try:
484
+ import json
485
+ with open(manifest_path, 'w') as f:
486
+ json.dump({'jinxs': list(current_package_jinxs), 'updated': str(__import__('datetime').datetime.now())}, f, indent=2)
487
+ except Exception as e:
488
+ print(f"Could not save jinx manifest: {e}")
397
489
 
398
490
  # Copy templates directory
399
491
  templates = os.path.join(package_npc_team_dir, "templates")
@@ -1046,6 +1138,31 @@ def set_npcsh_initialized() -> None:
1046
1138
 
1047
1139
 
1048
1140
 
1141
+ def get_package_dir() -> str:
1142
+ """
1143
+ Get the package directory, handling both normal Python and PyInstaller executables.
1144
+
1145
+ For normal Python: returns os.path.dirname(__file__)
1146
+ For PyInstaller: returns the bundled data directory (sys._MEIPASS/npcsh)
1147
+ """
1148
+ # Check if running as a PyInstaller bundle
1149
+ if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
1150
+ # Running as PyInstaller bundle - look for npcsh folder in _MEIPASS
1151
+ meipass = sys._MEIPASS
1152
+ # The package data should be at _MEIPASS/npcsh (based on PyInstaller config)
1153
+ bundled_path = os.path.join(meipass, 'npcsh')
1154
+ if os.path.exists(bundled_path):
1155
+ return bundled_path
1156
+ # Fallback: check if npc_team is directly in _MEIPASS
1157
+ if os.path.exists(os.path.join(meipass, 'npc_team')):
1158
+ return meipass
1159
+ # Last resort: return meipass and let caller handle
1160
+ return meipass
1161
+ else:
1162
+ # Normal Python execution
1163
+ return os.path.dirname(__file__)
1164
+
1165
+
1049
1166
  def file_has_changed(source_path: str, destination_path: str) -> bool:
1050
1167
  """
1051
1168
  Function Description:
@@ -1059,7 +1176,7 @@ def file_has_changed(source_path: str, destination_path: str) -> bool:
1059
1176
  A boolean indicating whether the files are different
1060
1177
  """
1061
1178
 
1062
-
1179
+
1063
1180
  return not filecmp.cmp(source_path, destination_path, shallow=False)
1064
1181
 
1065
1182
 
@@ -1548,29 +1665,134 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1548
1665
 
1549
1666
  sys.stdout.flush()
1550
1667
 
1668
+ # Enable bracketed paste mode - terminal will wrap pastes with escape sequences
1669
+ sys.stdout.write('\033[?2004h')
1670
+
1551
1671
  # Print prompt and reserve hint line
1552
1672
  sys.stdout.write(prompt + '\n' + (token_hint or '') + '\033[A\r')
1553
1673
  if prompt_visible_len > 0:
1554
1674
  sys.stdout.write('\033[' + str(prompt_visible_len) + 'C')
1555
1675
  sys.stdout.flush()
1556
1676
 
1677
+ # Store pasted content separately
1678
+ pasted_content = None
1679
+ in_paste = False
1680
+ paste_buffer = ""
1681
+
1682
+ # Track Ctrl+C for double-press exit
1683
+ import time
1684
+ last_ctrl_c_time = 0
1685
+
1557
1686
  try:
1558
1687
  tty.setcbreak(fd)
1688
+
1559
1689
  while True:
1560
1690
  c = sys.stdin.read(1)
1561
1691
 
1562
- if c in ('\n', '\r'):
1563
- # Clear hint and newline
1692
+ if not c: # EOF/stdin closed
1693
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1564
1694
  sys.stdout.write('\n\033[K')
1565
1695
  sys.stdout.flush()
1566
- if buf.strip():
1567
- readline.add_history(buf)
1568
- return buf
1696
+ raise EOFError
1569
1697
 
1570
- elif c == '\x1b': # ESC - could be arrow key
1698
+ # Check for bracketed paste start: ESC [ 2 0 0 ~
1699
+ if c == '\x1b':
1571
1700
  c2 = sys.stdin.read(1)
1572
1701
  if c2 == '[':
1573
1702
  c3 = sys.stdin.read(1)
1703
+ if c3 == '2':
1704
+ c4 = sys.stdin.read(1)
1705
+ if c4 == '0':
1706
+ c5 = sys.stdin.read(1)
1707
+ if c5 == '0':
1708
+ c6 = sys.stdin.read(1)
1709
+ if c6 == '~':
1710
+ # Start of bracketed paste
1711
+ in_paste = True
1712
+ paste_buffer = ""
1713
+ continue
1714
+ elif c5 == '1':
1715
+ c6 = sys.stdin.read(1)
1716
+ if c6 == '~':
1717
+ # End of bracketed paste ESC [ 2 0 1 ~
1718
+ in_paste = False
1719
+ if paste_buffer:
1720
+ # Check if this looks like binary/image data
1721
+ # Image signatures: PNG (\x89PNG), JPEG (\xff\xd8\xff), GIF (GIF8), BMP (BM)
1722
+ # Also check for high ratio of non-printable chars
1723
+ is_binary = False
1724
+ if len(paste_buffer) > 4:
1725
+ # Check for common image magic bytes
1726
+ if paste_buffer[:4] == '\x89PNG' or paste_buffer[:8] == '\x89PNG\r\n\x1a\n':
1727
+ is_binary = True
1728
+ elif paste_buffer[:2] == '\xff\xd8': # JPEG
1729
+ is_binary = True
1730
+ elif paste_buffer[:4] == 'GIF8': # GIF
1731
+ is_binary = True
1732
+ elif paste_buffer[:2] == 'BM': # BMP
1733
+ is_binary = True
1734
+ elif paste_buffer.startswith('data:image/'): # Base64 data URL
1735
+ is_binary = True
1736
+ else:
1737
+ # Check for high ratio of non-printable characters
1738
+ non_printable = sum(1 for c in paste_buffer[:100] if ord(c) < 32 and c not in '\n\r\t')
1739
+ if non_printable > 10: # More than 10% non-printable in first 100 chars
1740
+ is_binary = True
1741
+
1742
+ if is_binary:
1743
+ # Save image data to temp file
1744
+ import tempfile
1745
+ import os
1746
+ try:
1747
+ # Determine extension from magic bytes
1748
+ ext = '.bin'
1749
+ if '\x89PNG' in paste_buffer[:8]:
1750
+ ext = '.png'
1751
+ elif paste_buffer[:2] == '\xff\xd8':
1752
+ ext = '.jpg'
1753
+ elif paste_buffer[:4] == 'GIF8':
1754
+ ext = '.gif'
1755
+ elif paste_buffer.startswith('data:image/'):
1756
+ # Extract from data URL
1757
+ if 'png' in paste_buffer[:30]:
1758
+ ext = '.png'
1759
+ elif 'jpeg' in paste_buffer[:30] or 'jpg' in paste_buffer[:30]:
1760
+ ext = '.jpg'
1761
+ elif 'gif' in paste_buffer[:30]:
1762
+ ext = '.gif'
1763
+
1764
+ fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='npcsh_paste_')
1765
+ with os.fdopen(fd, 'wb') as f:
1766
+ if paste_buffer.startswith('data:image/'):
1767
+ # Decode base64 data URL
1768
+ import base64
1769
+ _, data = paste_buffer.split(',', 1)
1770
+ f.write(base64.b64decode(data))
1771
+ else:
1772
+ f.write(paste_buffer.encode('latin-1'))
1773
+ pasted_content = temp_path # Store path to image
1774
+ placeholder = f"[pasted image: {temp_path}]"
1775
+ except:
1776
+ pasted_content = None
1777
+ placeholder = "[pasted image: failed to save]"
1778
+ else:
1779
+ pasted_content = paste_buffer.rstrip('\r\n')
1780
+ line_count = pasted_content.count('\n') + 1
1781
+ char_count = len(pasted_content)
1782
+ if line_count > 1:
1783
+ placeholder = f"[pasted: {line_count} lines, {char_count} chars]"
1784
+ else:
1785
+ # Single line paste - just insert it directly
1786
+ buf = buf[:pos] + pasted_content + buf[pos:]
1787
+ pos += len(pasted_content)
1788
+ pasted_content = None # Clear so we don't replace on submit
1789
+ draw()
1790
+ continue
1791
+ buf = buf[:pos] + placeholder + buf[pos:]
1792
+ pos += len(placeholder)
1793
+ draw()
1794
+ continue
1795
+ # Handle arrow keys and other escape sequences
1574
1796
  if c3 == 'A': # Up
1575
1797
  if history_idx > 0:
1576
1798
  if history_idx == len(history):
@@ -1579,37 +1801,78 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1579
1801
  buf = history[history_idx] or ''
1580
1802
  pos = len(buf)
1581
1803
  draw()
1804
+ continue
1582
1805
  elif c3 == 'B': # Down
1583
1806
  if history_idx < len(history):
1584
1807
  history_idx += 1
1585
1808
  buf = saved_line if history_idx == len(history) else (history[history_idx] or '')
1586
1809
  pos = len(buf)
1587
1810
  draw()
1811
+ continue
1588
1812
  elif c3 == 'C': # Right
1589
1813
  if pos < len(buf):
1590
1814
  pos += 1
1591
1815
  sys.stdout.write('\033[C')
1592
1816
  sys.stdout.flush()
1817
+ continue
1593
1818
  elif c3 == 'D': # Left
1594
1819
  if pos > 0:
1595
1820
  pos -= 1
1596
1821
  sys.stdout.write('\033[D')
1597
1822
  sys.stdout.flush()
1823
+ continue
1598
1824
  elif c3 == '3': # Del
1599
1825
  sys.stdin.read(1) # ~
1600
1826
  if pos < len(buf):
1601
1827
  buf = buf[:pos] + buf[pos+1:]
1602
1828
  draw()
1829
+ continue
1603
1830
  elif c3 == 'H': # Home
1604
1831
  pos = 0
1605
1832
  draw()
1833
+ continue
1606
1834
  elif c3 == 'F': # End
1607
1835
  pos = len(buf)
1608
1836
  draw()
1837
+ continue
1609
1838
  elif c2 == '\x1b': # Double ESC
1839
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1610
1840
  sys.stdout.write('\n\033[K')
1611
1841
  sys.stdout.flush()
1612
1842
  return '\x1b'
1843
+ continue
1844
+
1845
+ # If we're in a paste, accumulate to paste buffer
1846
+ if in_paste:
1847
+ paste_buffer += c
1848
+ continue
1849
+
1850
+ if c in ('\n', '\r'):
1851
+ # Clear hint and newline
1852
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1853
+ sys.stdout.write('\n\033[K')
1854
+ sys.stdout.flush()
1855
+ # If we have pasted content, replace placeholder with actual content
1856
+ if pasted_content is not None:
1857
+ import re
1858
+ # Escape pipe characters in pasted content so they aren't parsed as pipeline operators
1859
+ escaped_content = pasted_content.replace('|', '\\|')
1860
+ # If pasted content is at the start of command, escape @ and / to prevent
1861
+ # them being interpreted as delegation or slash commands
1862
+ placeholder_pattern = r'\[pasted: \d+ lines?, \d+ chars?\]'
1863
+ if re.match(placeholder_pattern, buf.lstrip()):
1864
+ if escaped_content.startswith('@'):
1865
+ escaped_content = '\\@' + escaped_content[1:]
1866
+ elif escaped_content.startswith('/'):
1867
+ escaped_content = '\\/' + escaped_content[1:]
1868
+ # Use lambda to avoid backreference issues in replacement string
1869
+ result = re.sub(placeholder_pattern, lambda m: escaped_content, buf)
1870
+ if result.strip():
1871
+ readline.add_history(result)
1872
+ return result
1873
+ if buf.strip():
1874
+ readline.add_history(buf)
1875
+ return buf
1613
1876
 
1614
1877
  elif c == '\x7f' or c == '\x08': # Backspace
1615
1878
  if pos > 0:
@@ -1618,9 +1881,27 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1618
1881
  draw()
1619
1882
 
1620
1883
  elif c == '\x03': # Ctrl-C
1621
- sys.stdout.write('\n\033[K')
1622
- sys.stdout.flush()
1623
- raise KeyboardInterrupt
1884
+ current_time = time.time()
1885
+ if current_time - last_ctrl_c_time < 1.0: # Double Ctrl+C within 1 second
1886
+ # Exit
1887
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1888
+ sys.stdout.write('\n\033[K')
1889
+ sys.stdout.flush()
1890
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1891
+ raise KeyboardInterrupt
1892
+ else:
1893
+ # First Ctrl+C - clear the line
1894
+ last_ctrl_c_time = current_time
1895
+ buf = ""
1896
+ pos = 0
1897
+ pasted_content = None
1898
+ sys.stdout.write('\r\033[K') # Clear current line
1899
+ sys.stdout.write('^C\n')
1900
+ # Redraw prompt
1901
+ sys.stdout.write(prompt + '\n' + current_hint() + '\033[A\r')
1902
+ if prompt_visible_len > 0:
1903
+ sys.stdout.write('\033[' + str(prompt_visible_len) + 'C')
1904
+ sys.stdout.flush()
1624
1905
 
1625
1906
  elif c == '\x04': # Ctrl-D
1626
1907
  if not buf:
@@ -1692,12 +1973,14 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1692
1973
  except:
1693
1974
  pass
1694
1975
 
1695
- elif ord(c) >= 32: # Printable
1976
+ elif c and ord(c) >= 32: # Printable
1696
1977
  buf = buf[:pos] + c + buf[pos:]
1697
1978
  pos += 1
1698
1979
  draw()
1699
1980
 
1700
1981
  finally:
1982
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste mode
1983
+ sys.stdout.flush()
1701
1984
  termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1702
1985
 
1703
1986
 
@@ -2123,6 +2406,96 @@ def model_supports_tool_calls(model: Optional[str], provider: Optional[str]) ->
2123
2406
  return any(marker in model_lower for marker in toolish_markers)
2124
2407
 
2125
2408
 
2409
+ def wrap_tool_with_display(tool_name: str, tool_func: Callable, state: ShellState) -> Callable:
2410
+ """Wrap a tool function to add visual feedback when it executes.
2411
+
2412
+ Respects state.log_level:
2413
+ - "silent": no output
2414
+ - "normal": show tool name and success/failure
2415
+ - "verbose": show tool name, args, success/failure, and result preview
2416
+ """
2417
+ def wrapped(**kwargs):
2418
+ log_level = getattr(state, 'log_level', 'normal')
2419
+
2420
+ # Display tool call (skip in silent mode)
2421
+ if log_level != "silent":
2422
+ try:
2423
+ args_display = ""
2424
+ # Always show a preview of args for key tools
2425
+ if kwargs:
2426
+ # For sh/python/sql, show the code/command being run
2427
+ if tool_name in ('sh', 'python', 'sql', 'cmd') and 'code' in kwargs:
2428
+ code_preview = str(kwargs['code']).strip().split('\n')[0] # First line
2429
+ if len(code_preview) > 80:
2430
+ code_preview = code_preview[:80] + "…"
2431
+ args_display = code_preview
2432
+ elif tool_name == 'sh' and 'bash_command' in kwargs:
2433
+ cmd_preview = str(kwargs['bash_command']).strip().split('\n')[0]
2434
+ if len(cmd_preview) > 80:
2435
+ cmd_preview = cmd_preview[:80] + "…"
2436
+ args_display = cmd_preview
2437
+ elif tool_name in ('sh', 'cmd') and 'command' in kwargs:
2438
+ cmd_preview = str(kwargs['command']).strip().split('\n')[0]
2439
+ if len(cmd_preview) > 80:
2440
+ cmd_preview = cmd_preview[:80] + "…"
2441
+ args_display = cmd_preview
2442
+ elif tool_name == 'python' and 'python_code' in kwargs:
2443
+ code_preview = str(kwargs['python_code']).strip().split('\n')[0]
2444
+ if len(code_preview) > 80:
2445
+ code_preview = code_preview[:80] + "…"
2446
+ args_display = code_preview
2447
+ elif tool_name == 'agent' and 'npc_name' in kwargs:
2448
+ args_display = f"@{kwargs['npc_name']}"
2449
+ if 'request' in kwargs:
2450
+ req = str(kwargs['request'])[:50]
2451
+ args_display += f": {req}…" if len(str(kwargs['request'])) > 50 else f": {req}"
2452
+ elif tool_name == 'agent' and 'query' in kwargs:
2453
+ query_preview = str(kwargs['query']).strip()[:60]
2454
+ args_display = query_preview + ("…" if len(str(kwargs['query'])) > 60 else "")
2455
+ elif log_level == "verbose":
2456
+ arg_parts = []
2457
+ for _, v in kwargs.items():
2458
+ v_str = str(v)
2459
+ if len(v_str) > 40:
2460
+ v_str = v_str[:40] + "…"
2461
+ arg_parts.append(f"{v_str}")
2462
+ args_display = " ".join(arg_parts)
2463
+ if len(args_display) > 60:
2464
+ args_display = args_display[:60] + "…"
2465
+
2466
+ if args_display:
2467
+ print(colored(f" ⚡ {tool_name}", "cyan") + colored(f" {args_display}", "white", attrs=["dark"]), end="", flush=True)
2468
+ else:
2469
+ print(colored(f" ⚡ {tool_name}", "cyan"), end="", flush=True)
2470
+ except:
2471
+ pass
2472
+
2473
+ # Execute tool
2474
+ try:
2475
+ result = tool_func(**kwargs)
2476
+ if log_level != "silent":
2477
+ try:
2478
+ print(colored(" ✓", "green"), flush=True)
2479
+ # Show preview of result only in verbose mode
2480
+ if log_level == "verbose":
2481
+ result_preview = str(result)
2482
+ if len(result_preview) > 200:
2483
+ result_preview = result_preview[:200] + "..."
2484
+ if result_preview and result_preview not in ('None', '', '{}', '[]'):
2485
+ print(colored(f" → {result_preview}", "white", attrs=["dark"]), flush=True)
2486
+ except:
2487
+ pass
2488
+ return result
2489
+ except Exception as e:
2490
+ if log_level != "silent":
2491
+ try:
2492
+ print(colored(f" ✗ {str(e)[:100]}", "red"), flush=True)
2493
+ except:
2494
+ pass
2495
+ raise
2496
+ return wrapped
2497
+
2498
+
2126
2499
  def collect_llm_tools(state: ShellState) -> Tuple[List[Dict[str, Any]], Dict[str, Callable]]:
2127
2500
  """
2128
2501
  Assemble tool definitions + executable map from NPC tools, Jinxs, and MCP servers.
@@ -2230,7 +2603,11 @@ def collect_llm_tools(state: ShellState) -> Tuple[List[Dict[str, Any]], Dict[str
2230
2603
  name = tool_def.get("function", {}).get("name")
2231
2604
  if name:
2232
2605
  deduped[name] = tool_def
2233
- return list(deduped.values()), tool_map
2606
+
2607
+ # Wrap all tools with display feedback for npcsh
2608
+ wrapped_tool_map = {name: wrap_tool_with_display(name, func, state) for name, func in tool_map.items()}
2609
+
2610
+ return list(deduped.values()), wrapped_tool_map
2234
2611
 
2235
2612
 
2236
2613
  def should_skip_kg_processing(user_input: str, assistant_output: str) -> bool:
@@ -2454,15 +2831,19 @@ def process_pipeline_command(
2454
2831
  else cmd_to_process
2455
2832
  )
2456
2833
  path_cmd = 'The current working directory is: ' + state.current_path
2457
- ls_files = (
2458
- 'Files in the current directory (full paths):\n' +
2459
- "\n".join([
2460
- os.path.join(state.current_path, f)
2461
- for f in os.listdir(state.current_path)
2462
- ])
2463
- if os.path.exists(state.current_path)
2464
- else 'No files found in the current directory.'
2465
- )
2834
+ if os.path.exists(state.current_path):
2835
+ all_files = os.listdir(state.current_path)
2836
+ # Limit to first 100 files to avoid token explosion
2837
+ limited_files = all_files[:100]
2838
+ file_list = "\n".join([
2839
+ os.path.join(state.current_path, f)
2840
+ for f in limited_files
2841
+ ])
2842
+ if len(all_files) > 100:
2843
+ file_list += f"\n... and {len(all_files) - 100} more files"
2844
+ ls_files = 'Files in the current directory (full paths):\n' + file_list
2845
+ else:
2846
+ ls_files = 'No files found in the current directory.'
2466
2847
  platform_info = (
2467
2848
  f"Platform: {platform.system()} {platform.release()} "
2468
2849
  f"({platform.machine()})"
@@ -2526,18 +2907,66 @@ def process_pipeline_command(
2526
2907
  (exec_model and "gemini" in exec_model.lower())
2527
2908
  llm_kwargs["tool_choice"] = 'auto'
2528
2909
 
2529
- llm_result = get_llm_response(
2530
- full_llm_cmd,
2531
- model=exec_model,
2532
- provider=exec_provider,
2533
- npc=state.npc,
2534
- team=state.team,
2535
- messages=state.messages,
2536
- stream=stream_final,
2537
- attachments=state.attachments,
2538
- context=info,
2539
- **llm_kwargs,
2540
- )
2910
+ # Agent loop: keep calling LLM until it stops making tool calls
2911
+ # The LLM decides when it's done - npcsh just facilitates
2912
+ iteration = 0
2913
+ max_iterations = 50 # Safety limit to prevent infinite loops
2914
+ total_usage = {"input_tokens": 0, "output_tokens": 0}
2915
+
2916
+ while iteration < max_iterations:
2917
+ iteration += 1
2918
+
2919
+ llm_result = get_llm_response(
2920
+ full_llm_cmd if iteration == 1 else None, # Only pass prompt on first call
2921
+ model=exec_model,
2922
+ provider=exec_provider,
2923
+ npc=state.npc,
2924
+ team=state.team,
2925
+ messages=state.messages,
2926
+ stream=False, # Don't stream intermediate calls
2927
+ attachments=state.attachments if iteration == 1 else None,
2928
+ context=info if iteration == 1 else None,
2929
+ **llm_kwargs,
2930
+ )
2931
+
2932
+ # Accumulate usage
2933
+ if isinstance(llm_result, dict) and llm_result.get('usage'):
2934
+ total_usage["input_tokens"] += llm_result['usage'].get('input_tokens', 0)
2935
+ total_usage["output_tokens"] += llm_result['usage'].get('output_tokens', 0)
2936
+
2937
+ # Update state messages
2938
+ old_msg_count = len(state.messages) if state.messages else 0
2939
+ if isinstance(llm_result, dict):
2940
+ state.messages = llm_result.get("messages", state.messages)
2941
+
2942
+ # Display tool outputs from this iteration
2943
+ for msg in state.messages[old_msg_count:]:
2944
+ if msg.get("role") == "tool":
2945
+ tool_name = msg.get("name", "tool")
2946
+ tool_content = msg.get("content", "")
2947
+ if tool_content and tool_content.strip():
2948
+ print(colored(f"\n⚡ {tool_name}:", "cyan"))
2949
+ lines = tool_content.split('\n')
2950
+ if len(lines) > 50:
2951
+ print('\n'.join(lines[:25]))
2952
+ print(colored(f"\n... ({len(lines) - 50} lines hidden) ...\n", "white", attrs=["dark"]))
2953
+ print('\n'.join(lines[-25:]))
2954
+ else:
2955
+ print(tool_content)
2956
+
2957
+ # Check if LLM made tool calls - if not, it's done
2958
+ tool_calls_made = isinstance(llm_result, dict) and llm_result.get("tool_calls")
2959
+ if not tool_calls_made:
2960
+ # LLM is done - no more tool calls
2961
+ break
2962
+
2963
+ # Clear the prompt for continuation calls - context is in messages
2964
+ full_llm_cmd = None
2965
+
2966
+ # Store accumulated usage
2967
+ if isinstance(llm_result, dict):
2968
+ llm_result['usage'] = total_usage
2969
+
2541
2970
  else:
2542
2971
  llm_result = check_llm_command(
2543
2972
  full_llm_cmd,
@@ -2781,6 +3210,11 @@ def execute_command(
2781
3210
  if not command.strip():
2782
3211
  return state, ""
2783
3212
 
3213
+ # Unescape @ and / at start of command that were escaped to prevent misinterpretation
3214
+ # (e.g., from pasted content that starts with @ or /)
3215
+ if command.startswith('\\@') or command.startswith('\\/'):
3216
+ command = command[1:] # Remove the escape backslash
3217
+
2784
3218
  # Check for mode switch commands
2785
3219
  mode_change, state = check_mode_switch(command, state)
2786
3220
  if mode_change:
@@ -2802,6 +3236,8 @@ def execute_command(
2802
3236
 
2803
3237
  original_command_for_embedding = command
2804
3238
  commands = split_by_pipes(command)
3239
+ # Unescape pipe characters that were escaped to prevent splitting (e.g., from pasted content)
3240
+ commands = [cmd.replace('\\|', '|') for cmd in commands]
2805
3241
 
2806
3242
  stdin_for_next = None
2807
3243
  final_output = None
@@ -2993,32 +3429,44 @@ def setup_shell() -> Tuple[CommandHistory, Team, Optional[NPC]]:
2993
3429
  default_forenpc_name = "forenpc"
2994
3430
  else:
2995
3431
  if not os.path.exists('.npcsh_global'):
2996
- resp = input(f"No npc_team found in {os.getcwd()}. Create a new team here? [Y/n]: ").strip().lower()
3432
+ try:
3433
+ resp = input(f"No npc_team found in {os.getcwd()}. Create a new team here? [Y/n]: ").strip().lower()
3434
+ except (KeyboardInterrupt, EOFError):
3435
+ print("\nAborted.")
3436
+ sys.exit(0)
2997
3437
  if resp in ("", "y", "yes"):
2998
3438
  team_dir = project_team_path
2999
3439
  os.makedirs(team_dir, exist_ok=True)
3000
3440
  default_forenpc_name = "forenpc"
3001
- forenpc_directive = input(
3002
- f"Enter a primary directive for {default_forenpc_name} (default: 'You are the forenpc of the team...'): "
3003
- ).strip() or "You are the forenpc of the team, coordinating activities between NPCs on the team, verifying that results from NPCs are high quality and can help to adequately answer user requests."
3004
- forenpc_model = input("Enter a model for your forenpc (default: llama3.2): ").strip() or "llama3.2"
3005
- forenpc_provider = input("Enter a provider for your forenpc (default: ollama): ").strip() or "ollama"
3006
-
3441
+ try:
3442
+ forenpc_directive = input(
3443
+ f"Enter a primary directive for {default_forenpc_name} (default: 'You are the forenpc of the team...'): "
3444
+ ).strip() or "You are the forenpc of the team, coordinating activities between NPCs on the team, verifying that results from NPCs are high quality and can help to adequately answer user requests."
3445
+ forenpc_model = input("Enter a model for your forenpc (default: llama3.2): ").strip() or "llama3.2"
3446
+ forenpc_provider = input("Enter a provider for your forenpc (default: ollama): ").strip() or "ollama"
3447
+ except (KeyboardInterrupt, EOFError):
3448
+ print("\nAborted.")
3449
+ sys.exit(0)
3450
+
3007
3451
  with open(os.path.join(team_dir, f"{default_forenpc_name}.npc"), "w") as f:
3008
3452
  yaml.dump({
3009
3453
  "name": default_forenpc_name, "primary_directive": forenpc_directive,
3010
3454
  "model": forenpc_model, "provider": forenpc_provider
3011
3455
  }, f)
3012
-
3456
+
3013
3457
  ctx_path = os.path.join(team_dir, "team.ctx")
3014
- folder_context = input("Enter a short description for this project/team (optional): ").strip()
3015
- team_ctx_data = {
3016
- "forenpc": default_forenpc_name,
3017
- "model": forenpc_model,
3018
- "provider": forenpc_provider,
3019
- "context": folder_context if folder_context else None
3020
- }
3021
- use_jinxs = input("Use global jinxs folder (g) or copy to this project (c)? [g/c, default: g]: ").strip().lower()
3458
+ try:
3459
+ folder_context = input("Enter a short description for this project/team (optional): ").strip()
3460
+ team_ctx_data = {
3461
+ "forenpc": default_forenpc_name,
3462
+ "model": forenpc_model,
3463
+ "provider": forenpc_provider,
3464
+ "context": folder_context if folder_context else None
3465
+ }
3466
+ use_jinxs = input("Use global jinxs folder (g) or copy to this project (c)? [g/c, default: g]: ").strip().lower()
3467
+ except (KeyboardInterrupt, EOFError):
3468
+ print("\nAborted.")
3469
+ sys.exit(0)
3022
3470
  if use_jinxs == "c":
3023
3471
  global_jinxs_dir = os.path.expanduser("~/.npcsh/npc_team/jinxs")
3024
3472
  if os.path.exists(global_jinxs_dir):
@@ -3035,7 +3483,7 @@ def setup_shell() -> Tuple[CommandHistory, Team, Optional[NPC]]:
3035
3483
  with open(".npcsh_global", "w") as f:
3036
3484
  pass
3037
3485
  team_dir = global_team_path
3038
- default_forenpc_name = "sibiji"
3486
+ default_forenpc_name = "sibiji"
3039
3487
  else:
3040
3488
  team_dir = global_team_path
3041
3489
  default_forenpc_name = "sibiji"
@@ -3195,7 +3643,8 @@ def process_result(
3195
3643
 
3196
3644
  # FIX: Handle dict output properly
3197
3645
  if isinstance(output, dict):
3198
- output_content = output.get('output')
3646
+ # Use None-safe check to not skip empty strings
3647
+ output_content = output.get('output') if 'output' in output else output.get('response')
3199
3648
  model_for_stream = output.get('model', active_npc.model)
3200
3649
  provider_for_stream = output.get('provider', active_npc.provider)
3201
3650
 
@@ -3212,18 +3661,22 @@ def process_result(
3212
3661
  usage.get('output_tokens', 0)
3213
3662
  )
3214
3663
 
3215
- # If output_content is still a dict or None, convert to string
3664
+ # If output_content is still a dict, convert to string
3216
3665
  if isinstance(output_content, dict):
3217
3666
  output_content = str(output_content)
3218
- elif output_content is None:
3219
- output_content = "Command completed with no output"
3667
+ elif output_content is None or output_content == '':
3668
+ # No output from the agent - this is fine, don't show annoying message
3669
+ output_content = None
3220
3670
  else:
3221
3671
  output_content = output
3222
3672
  model_for_stream = active_npc.model
3223
3673
  provider_for_stream = active_npc.provider
3224
3674
 
3225
3675
  print('\n')
3226
- if user_input == '/help':
3676
+ if output_content is None:
3677
+ # No output to display - tool results already shown during execution
3678
+ pass
3679
+ elif user_input == '/help':
3227
3680
  if isinstance(output_content, str):
3228
3681
  render_markdown(output_content)
3229
3682
  else:
@@ -3235,12 +3688,12 @@ def process_result(
3235
3688
  render_markdown(final_output_str)
3236
3689
  else:
3237
3690
  final_output_str = print_and_process_stream_with_markdown(
3238
- output_content,
3239
- model_for_stream,
3240
- provider_for_stream,
3691
+ output_content,
3692
+ model_for_stream,
3693
+ provider_for_stream,
3241
3694
  show=True
3242
3695
  )
3243
- elif output_content is not None:
3696
+ else:
3244
3697
  final_output_str = str(output_content)
3245
3698
  render_markdown(final_output_str)
3246
3699