npcsh 1.1.14__py3-none-any.whl → 1.1.15__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 +488 -77
  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.15.data/data/npcsh/npc_team/alicanto.npc +23 -0
  47. npcsh-1.1.15.data/data/npcsh/npc_team/arxiv.jinx +76 -0
  48. npcsh-1.1.15.data/data/npcsh/npc_team/browser_action.jinx +220 -0
  49. npcsh-1.1.15.data/data/npcsh/npc_team/browser_screenshot.jinx +40 -0
  50. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/build.jinx +8 -8
  51. npcsh-1.1.15.data/data/npcsh/npc_team/click.jinx +23 -0
  52. npcsh-1.1.15.data/data/npcsh/npc_team/close_browser.jinx +14 -0
  53. npcsh-1.1.15.data/data/npcsh/npc_team/convene.jinx +232 -0
  54. npcsh-1.1.15.data/data/npcsh/npc_team/corca.npc +31 -0
  55. npcsh-1.1.15.data/data/npcsh/npc_team/delegate.jinx +184 -0
  56. {npcsh/npc_team/jinxs/utils → npcsh-1.1.15.data/data/npcsh/npc_team}/edit_file.jinx +3 -3
  57. npcsh-1.1.15.data/data/npcsh/npc_team/frederic.npc +27 -0
  58. npcsh-1.1.15.data/data/npcsh/npc_team/guac.npc +22 -0
  59. npcsh-1.1.15.data/data/npcsh/npc_team/jinxs.jinx +176 -0
  60. npcsh-1.1.15.data/data/npcsh/npc_team/kadiefa.npc +21 -0
  61. npcsh-1.1.15.data/data/npcsh/npc_team/key_press.jinx +26 -0
  62. npcsh-1.1.15.data/data/npcsh/npc_team/launch_app.jinx +37 -0
  63. {npcsh/npc_team/jinxs/utils → npcsh-1.1.15.data/data/npcsh/npc_team}/load_file.jinx +1 -1
  64. npcsh-1.1.15.data/data/npcsh/npc_team/nql.jinx +141 -0
  65. npcsh-1.1.15.data/data/npcsh/npc_team/open_browser.jinx +43 -0
  66. npcsh-1.1.15.data/data/npcsh/npc_team/paper_search.jinx +101 -0
  67. npcsh-1.1.15.data/data/npcsh/npc_team/paste.jinx +134 -0
  68. npcsh-1.1.15.data/data/npcsh/npc_team/plonk.npc +27 -0
  69. npcsh-1.1.15.data/data/npcsh/npc_team/plonkjr.npc +23 -0
  70. npcsh-1.1.15.data/data/npcsh/npc_team/screenshot.jinx +23 -0
  71. {npcsh/npc_team/jinxs/utils → npcsh-1.1.15.data/data/npcsh/npc_team}/search.jinx +2 -1
  72. npcsh-1.1.15.data/data/npcsh/npc_team/semantic_scholar.jinx +69 -0
  73. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/sh.jinx +2 -8
  74. npcsh-1.1.15.data/data/npcsh/npc_team/shh.jinx +17 -0
  75. npcsh-1.1.15.data/data/npcsh/npc_team/sibiji.npc +24 -0
  76. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/sql.jinx +1 -1
  77. npcsh-1.1.15.data/data/npcsh/npc_team/switch.jinx +62 -0
  78. npcsh-1.1.15.data/data/npcsh/npc_team/switches.jinx +61 -0
  79. npcsh-1.1.15.data/data/npcsh/npc_team/sync.jinx +230 -0
  80. npcsh-1.1.15.data/data/npcsh/npc_team/teamviz.jinx +205 -0
  81. npcsh-1.1.15.data/data/npcsh/npc_team/type_text.jinx +27 -0
  82. npcsh-1.1.15.data/data/npcsh/npc_team/verbose.jinx +17 -0
  83. {npcsh/npc_team/jinxs/utils → npcsh-1.1.15.data/data/npcsh/npc_team}/vixynt.jinx +8 -30
  84. npcsh-1.1.15.data/data/npcsh/npc_team/wait.jinx +21 -0
  85. npcsh-1.1.15.data/data/npcsh/npc_team/wander.jinx +152 -0
  86. {npcsh-1.1.14.dist-info → npcsh-1.1.15.dist-info}/METADATA +399 -58
  87. npcsh-1.1.15.dist-info/RECORD +170 -0
  88. npcsh-1.1.15.dist-info/entry_points.txt +19 -0
  89. npcsh-1.1.15.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.15.data}/data/npcsh/npc_team/alicanto.png +0 -0
  137. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/chat.jinx +0 -0
  138. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  139. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/compile.jinx +0 -0
  140. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/compress.jinx +0 -0
  141. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/corca.png +0 -0
  142. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/corca_example.png +0 -0
  143. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/frederic4.png +0 -0
  144. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/guac.png +0 -0
  145. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/help.jinx +0 -0
  146. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/init.jinx +0 -0
  147. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  148. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/npc-studio.jinx +0 -0
  149. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  150. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  151. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/ots.jinx +0 -0
  152. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/plonk.png +0 -0
  153. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  154. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/python.jinx +0 -0
  155. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/roll.jinx +0 -0
  156. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/sample.jinx +0 -0
  157. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/serve.jinx +0 -0
  158. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/set.jinx +0 -0
  159. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/sibiji.png +0 -0
  160. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  161. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/spool.jinx +0 -0
  162. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/spool.png +0 -0
  163. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  164. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/usage.jinx +0 -0
  165. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/yap.jinx +0 -0
  166. {npcsh-1.1.14.data → npcsh-1.1.15.data}/data/npcsh/npc_team/yap.png +0 -0
  167. {npcsh-1.1.14.dist-info → npcsh-1.1.15.dist-info}/WHEEL +0 -0
  168. {npcsh-1.1.14.dist-info → npcsh-1.1.15.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",
@@ -369,31 +399,76 @@ def initialize_base_npcs_if_needed(db_path: str) -> None:
369
399
  shutil.copy2(source_path, destination_path)
370
400
  print(f"Copied ctx {filename} to {destination_path}")
371
401
 
372
- # Copy jinxs directory RECURSIVELY
402
+ # Copy jinxs directory RECURSIVELY with manifest tracking
403
+ # This ensures we only sync package jinxs and can clean up old ones
373
404
  package_jinxs_dir = os.path.join(package_npc_team_dir, "jinxs")
405
+ manifest_path = os.path.join(user_jinxs_dir, ".package_manifest.json")
406
+
407
+ # Load existing manifest of package-synced jinxs
408
+ old_package_jinxs = set()
409
+ if os.path.exists(manifest_path):
410
+ try:
411
+ import json
412
+ with open(manifest_path, 'r') as f:
413
+ old_package_jinxs = set(json.load(f).get('jinxs', []))
414
+ except:
415
+ pass
416
+
417
+ # Track current package jinxs
418
+ current_package_jinxs = set()
419
+
374
420
  if os.path.exists(package_jinxs_dir):
375
421
  for root, dirs, files in os.walk(package_jinxs_dir):
376
422
  # Calculate relative path from package_jinxs_dir
377
423
  rel_path = os.path.relpath(root, package_jinxs_dir)
378
-
424
+
379
425
  # Create corresponding directory in user jinxs
380
426
  if rel_path == '.':
381
427
  dest_dir = user_jinxs_dir
382
428
  else:
383
429
  dest_dir = os.path.join(user_jinxs_dir, rel_path)
384
430
  os.makedirs(dest_dir, exist_ok=True)
385
-
431
+
386
432
  # Copy all .jinx files in this directory
387
433
  for filename in files:
388
434
  if filename.endswith(".jinx"):
389
435
  source_jinx_path = os.path.join(root, filename)
390
436
  destination_jinx_path = os.path.join(dest_dir, filename)
391
-
437
+ jinx_rel_path = os.path.join(rel_path, filename) if rel_path != '.' else filename
438
+ current_package_jinxs.add(jinx_rel_path)
439
+
392
440
  if not os.path.exists(destination_jinx_path) or file_has_changed(
393
441
  source_jinx_path, destination_jinx_path
394
442
  ):
395
443
  shutil.copy2(source_jinx_path, destination_jinx_path)
396
- print(f"Copied jinx {os.path.join(rel_path, filename)} to {destination_jinx_path}")
444
+ print(f"Copied jinx {jinx_rel_path} to {destination_jinx_path}")
445
+
446
+ # Clean up old package jinxs that are no longer in the package
447
+ # (but preserve user-created jinxs that were never in the manifest)
448
+ stale_jinxs = old_package_jinxs - current_package_jinxs
449
+ for stale_jinx in stale_jinxs:
450
+ stale_path = os.path.join(user_jinxs_dir, stale_jinx)
451
+ if os.path.exists(stale_path):
452
+ try:
453
+ os.remove(stale_path)
454
+ print(f"Removed stale package jinx: {stale_jinx}")
455
+ # Remove empty parent directories
456
+ parent_dir = os.path.dirname(stale_path)
457
+ while parent_dir != user_jinxs_dir:
458
+ if os.path.isdir(parent_dir) and not os.listdir(parent_dir):
459
+ os.rmdir(parent_dir)
460
+ print(f"Removed empty directory: {parent_dir}")
461
+ parent_dir = os.path.dirname(parent_dir)
462
+ except Exception as e:
463
+ print(f"Could not remove stale jinx {stale_jinx}: {e}")
464
+
465
+ # Save updated manifest
466
+ try:
467
+ import json
468
+ with open(manifest_path, 'w') as f:
469
+ json.dump({'jinxs': list(current_package_jinxs), 'updated': str(__import__('datetime').datetime.now())}, f, indent=2)
470
+ except Exception as e:
471
+ print(f"Could not save jinx manifest: {e}")
397
472
 
398
473
  # Copy templates directory
399
474
  templates = os.path.join(package_npc_team_dir, "templates")
@@ -1548,29 +1623,134 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1548
1623
 
1549
1624
  sys.stdout.flush()
1550
1625
 
1626
+ # Enable bracketed paste mode - terminal will wrap pastes with escape sequences
1627
+ sys.stdout.write('\033[?2004h')
1628
+
1551
1629
  # Print prompt and reserve hint line
1552
1630
  sys.stdout.write(prompt + '\n' + (token_hint or '') + '\033[A\r')
1553
1631
  if prompt_visible_len > 0:
1554
1632
  sys.stdout.write('\033[' + str(prompt_visible_len) + 'C')
1555
1633
  sys.stdout.flush()
1556
1634
 
1635
+ # Store pasted content separately
1636
+ pasted_content = None
1637
+ in_paste = False
1638
+ paste_buffer = ""
1639
+
1640
+ # Track Ctrl+C for double-press exit
1641
+ import time
1642
+ last_ctrl_c_time = 0
1643
+
1557
1644
  try:
1558
1645
  tty.setcbreak(fd)
1646
+
1559
1647
  while True:
1560
1648
  c = sys.stdin.read(1)
1561
1649
 
1562
- if c in ('\n', '\r'):
1563
- # Clear hint and newline
1650
+ if not c: # EOF/stdin closed
1651
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1564
1652
  sys.stdout.write('\n\033[K')
1565
1653
  sys.stdout.flush()
1566
- if buf.strip():
1567
- readline.add_history(buf)
1568
- return buf
1654
+ raise EOFError
1569
1655
 
1570
- elif c == '\x1b': # ESC - could be arrow key
1656
+ # Check for bracketed paste start: ESC [ 2 0 0 ~
1657
+ if c == '\x1b':
1571
1658
  c2 = sys.stdin.read(1)
1572
1659
  if c2 == '[':
1573
1660
  c3 = sys.stdin.read(1)
1661
+ if c3 == '2':
1662
+ c4 = sys.stdin.read(1)
1663
+ if c4 == '0':
1664
+ c5 = sys.stdin.read(1)
1665
+ if c5 == '0':
1666
+ c6 = sys.stdin.read(1)
1667
+ if c6 == '~':
1668
+ # Start of bracketed paste
1669
+ in_paste = True
1670
+ paste_buffer = ""
1671
+ continue
1672
+ elif c5 == '1':
1673
+ c6 = sys.stdin.read(1)
1674
+ if c6 == '~':
1675
+ # End of bracketed paste ESC [ 2 0 1 ~
1676
+ in_paste = False
1677
+ if paste_buffer:
1678
+ # Check if this looks like binary/image data
1679
+ # Image signatures: PNG (\x89PNG), JPEG (\xff\xd8\xff), GIF (GIF8), BMP (BM)
1680
+ # Also check for high ratio of non-printable chars
1681
+ is_binary = False
1682
+ if len(paste_buffer) > 4:
1683
+ # Check for common image magic bytes
1684
+ if paste_buffer[:4] == '\x89PNG' or paste_buffer[:8] == '\x89PNG\r\n\x1a\n':
1685
+ is_binary = True
1686
+ elif paste_buffer[:2] == '\xff\xd8': # JPEG
1687
+ is_binary = True
1688
+ elif paste_buffer[:4] == 'GIF8': # GIF
1689
+ is_binary = True
1690
+ elif paste_buffer[:2] == 'BM': # BMP
1691
+ is_binary = True
1692
+ elif paste_buffer.startswith('data:image/'): # Base64 data URL
1693
+ is_binary = True
1694
+ else:
1695
+ # Check for high ratio of non-printable characters
1696
+ non_printable = sum(1 for c in paste_buffer[:100] if ord(c) < 32 and c not in '\n\r\t')
1697
+ if non_printable > 10: # More than 10% non-printable in first 100 chars
1698
+ is_binary = True
1699
+
1700
+ if is_binary:
1701
+ # Save image data to temp file
1702
+ import tempfile
1703
+ import os
1704
+ try:
1705
+ # Determine extension from magic bytes
1706
+ ext = '.bin'
1707
+ if '\x89PNG' in paste_buffer[:8]:
1708
+ ext = '.png'
1709
+ elif paste_buffer[:2] == '\xff\xd8':
1710
+ ext = '.jpg'
1711
+ elif paste_buffer[:4] == 'GIF8':
1712
+ ext = '.gif'
1713
+ elif paste_buffer.startswith('data:image/'):
1714
+ # Extract from data URL
1715
+ if 'png' in paste_buffer[:30]:
1716
+ ext = '.png'
1717
+ elif 'jpeg' in paste_buffer[:30] or 'jpg' in paste_buffer[:30]:
1718
+ ext = '.jpg'
1719
+ elif 'gif' in paste_buffer[:30]:
1720
+ ext = '.gif'
1721
+
1722
+ fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='npcsh_paste_')
1723
+ with os.fdopen(fd, 'wb') as f:
1724
+ if paste_buffer.startswith('data:image/'):
1725
+ # Decode base64 data URL
1726
+ import base64
1727
+ _, data = paste_buffer.split(',', 1)
1728
+ f.write(base64.b64decode(data))
1729
+ else:
1730
+ f.write(paste_buffer.encode('latin-1'))
1731
+ pasted_content = temp_path # Store path to image
1732
+ placeholder = f"[pasted image: {temp_path}]"
1733
+ except:
1734
+ pasted_content = None
1735
+ placeholder = "[pasted image: failed to save]"
1736
+ else:
1737
+ pasted_content = paste_buffer.rstrip('\r\n')
1738
+ line_count = pasted_content.count('\n') + 1
1739
+ char_count = len(pasted_content)
1740
+ if line_count > 1:
1741
+ placeholder = f"[pasted: {line_count} lines, {char_count} chars]"
1742
+ else:
1743
+ # Single line paste - just insert it directly
1744
+ buf = buf[:pos] + pasted_content + buf[pos:]
1745
+ pos += len(pasted_content)
1746
+ pasted_content = None # Clear so we don't replace on submit
1747
+ draw()
1748
+ continue
1749
+ buf = buf[:pos] + placeholder + buf[pos:]
1750
+ pos += len(placeholder)
1751
+ draw()
1752
+ continue
1753
+ # Handle arrow keys and other escape sequences
1574
1754
  if c3 == 'A': # Up
1575
1755
  if history_idx > 0:
1576
1756
  if history_idx == len(history):
@@ -1579,37 +1759,78 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1579
1759
  buf = history[history_idx] or ''
1580
1760
  pos = len(buf)
1581
1761
  draw()
1762
+ continue
1582
1763
  elif c3 == 'B': # Down
1583
1764
  if history_idx < len(history):
1584
1765
  history_idx += 1
1585
1766
  buf = saved_line if history_idx == len(history) else (history[history_idx] or '')
1586
1767
  pos = len(buf)
1587
1768
  draw()
1769
+ continue
1588
1770
  elif c3 == 'C': # Right
1589
1771
  if pos < len(buf):
1590
1772
  pos += 1
1591
1773
  sys.stdout.write('\033[C')
1592
1774
  sys.stdout.flush()
1775
+ continue
1593
1776
  elif c3 == 'D': # Left
1594
1777
  if pos > 0:
1595
1778
  pos -= 1
1596
1779
  sys.stdout.write('\033[D')
1597
1780
  sys.stdout.flush()
1781
+ continue
1598
1782
  elif c3 == '3': # Del
1599
1783
  sys.stdin.read(1) # ~
1600
1784
  if pos < len(buf):
1601
1785
  buf = buf[:pos] + buf[pos+1:]
1602
1786
  draw()
1787
+ continue
1603
1788
  elif c3 == 'H': # Home
1604
1789
  pos = 0
1605
1790
  draw()
1791
+ continue
1606
1792
  elif c3 == 'F': # End
1607
1793
  pos = len(buf)
1608
1794
  draw()
1795
+ continue
1609
1796
  elif c2 == '\x1b': # Double ESC
1797
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1610
1798
  sys.stdout.write('\n\033[K')
1611
1799
  sys.stdout.flush()
1612
1800
  return '\x1b'
1801
+ continue
1802
+
1803
+ # If we're in a paste, accumulate to paste buffer
1804
+ if in_paste:
1805
+ paste_buffer += c
1806
+ continue
1807
+
1808
+ if c in ('\n', '\r'):
1809
+ # Clear hint and newline
1810
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1811
+ sys.stdout.write('\n\033[K')
1812
+ sys.stdout.flush()
1813
+ # If we have pasted content, replace placeholder with actual content
1814
+ if pasted_content is not None:
1815
+ import re
1816
+ # Escape pipe characters in pasted content so they aren't parsed as pipeline operators
1817
+ escaped_content = pasted_content.replace('|', '\\|')
1818
+ # If pasted content is at the start of command, escape @ and / to prevent
1819
+ # them being interpreted as delegation or slash commands
1820
+ placeholder_pattern = r'\[pasted: \d+ lines?, \d+ chars?\]'
1821
+ if re.match(placeholder_pattern, buf.lstrip()):
1822
+ if escaped_content.startswith('@'):
1823
+ escaped_content = '\\@' + escaped_content[1:]
1824
+ elif escaped_content.startswith('/'):
1825
+ escaped_content = '\\/' + escaped_content[1:]
1826
+ # Use lambda to avoid backreference issues in replacement string
1827
+ result = re.sub(placeholder_pattern, lambda m: escaped_content, buf)
1828
+ if result.strip():
1829
+ readline.add_history(result)
1830
+ return result
1831
+ if buf.strip():
1832
+ readline.add_history(buf)
1833
+ return buf
1613
1834
 
1614
1835
  elif c == '\x7f' or c == '\x08': # Backspace
1615
1836
  if pos > 0:
@@ -1618,9 +1839,27 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1618
1839
  draw()
1619
1840
 
1620
1841
  elif c == '\x03': # Ctrl-C
1621
- sys.stdout.write('\n\033[K')
1622
- sys.stdout.flush()
1623
- raise KeyboardInterrupt
1842
+ current_time = time.time()
1843
+ if current_time - last_ctrl_c_time < 1.0: # Double Ctrl+C within 1 second
1844
+ # Exit
1845
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste
1846
+ sys.stdout.write('\n\033[K')
1847
+ sys.stdout.flush()
1848
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1849
+ raise KeyboardInterrupt
1850
+ else:
1851
+ # First Ctrl+C - clear the line
1852
+ last_ctrl_c_time = current_time
1853
+ buf = ""
1854
+ pos = 0
1855
+ pasted_content = None
1856
+ sys.stdout.write('\r\033[K') # Clear current line
1857
+ sys.stdout.write('^C\n')
1858
+ # Redraw prompt
1859
+ sys.stdout.write(prompt + '\n' + current_hint() + '\033[A\r')
1860
+ if prompt_visible_len > 0:
1861
+ sys.stdout.write('\033[' + str(prompt_visible_len) + 'C')
1862
+ sys.stdout.flush()
1624
1863
 
1625
1864
  elif c == '\x04': # Ctrl-D
1626
1865
  if not buf:
@@ -1692,12 +1931,14 @@ def _input_with_hint_below(prompt: str, state=None, router=None, token_hint: str
1692
1931
  except:
1693
1932
  pass
1694
1933
 
1695
- elif ord(c) >= 32: # Printable
1934
+ elif c and ord(c) >= 32: # Printable
1696
1935
  buf = buf[:pos] + c + buf[pos:]
1697
1936
  pos += 1
1698
1937
  draw()
1699
1938
 
1700
1939
  finally:
1940
+ sys.stdout.write('\033[?2004l') # Disable bracketed paste mode
1941
+ sys.stdout.flush()
1701
1942
  termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
1702
1943
 
1703
1944
 
@@ -2123,6 +2364,96 @@ def model_supports_tool_calls(model: Optional[str], provider: Optional[str]) ->
2123
2364
  return any(marker in model_lower for marker in toolish_markers)
2124
2365
 
2125
2366
 
2367
+ def wrap_tool_with_display(tool_name: str, tool_func: Callable, state: ShellState) -> Callable:
2368
+ """Wrap a tool function to add visual feedback when it executes.
2369
+
2370
+ Respects state.log_level:
2371
+ - "silent": no output
2372
+ - "normal": show tool name and success/failure
2373
+ - "verbose": show tool name, args, success/failure, and result preview
2374
+ """
2375
+ def wrapped(**kwargs):
2376
+ log_level = getattr(state, 'log_level', 'normal')
2377
+
2378
+ # Display tool call (skip in silent mode)
2379
+ if log_level != "silent":
2380
+ try:
2381
+ args_display = ""
2382
+ # Always show a preview of args for key tools
2383
+ if kwargs:
2384
+ # For sh/python/sql, show the code/command being run
2385
+ if tool_name in ('sh', 'python', 'sql', 'cmd') and 'code' in kwargs:
2386
+ code_preview = str(kwargs['code']).strip().split('\n')[0] # First line
2387
+ if len(code_preview) > 80:
2388
+ code_preview = code_preview[:80] + "…"
2389
+ args_display = code_preview
2390
+ elif tool_name == 'sh' and 'bash_command' in kwargs:
2391
+ cmd_preview = str(kwargs['bash_command']).strip().split('\n')[0]
2392
+ if len(cmd_preview) > 80:
2393
+ cmd_preview = cmd_preview[:80] + "…"
2394
+ args_display = cmd_preview
2395
+ elif tool_name in ('sh', 'cmd') and 'command' in kwargs:
2396
+ cmd_preview = str(kwargs['command']).strip().split('\n')[0]
2397
+ if len(cmd_preview) > 80:
2398
+ cmd_preview = cmd_preview[:80] + "…"
2399
+ args_display = cmd_preview
2400
+ elif tool_name == 'python' and 'python_code' in kwargs:
2401
+ code_preview = str(kwargs['python_code']).strip().split('\n')[0]
2402
+ if len(code_preview) > 80:
2403
+ code_preview = code_preview[:80] + "…"
2404
+ args_display = code_preview
2405
+ elif tool_name == 'agent' and 'npc_name' in kwargs:
2406
+ args_display = f"@{kwargs['npc_name']}"
2407
+ if 'request' in kwargs:
2408
+ req = str(kwargs['request'])[:50]
2409
+ args_display += f": {req}…" if len(str(kwargs['request'])) > 50 else f": {req}"
2410
+ elif tool_name == 'agent' and 'query' in kwargs:
2411
+ query_preview = str(kwargs['query']).strip()[:60]
2412
+ args_display = query_preview + ("…" if len(str(kwargs['query'])) > 60 else "")
2413
+ elif log_level == "verbose":
2414
+ arg_parts = []
2415
+ for _, v in kwargs.items():
2416
+ v_str = str(v)
2417
+ if len(v_str) > 40:
2418
+ v_str = v_str[:40] + "…"
2419
+ arg_parts.append(f"{v_str}")
2420
+ args_display = " ".join(arg_parts)
2421
+ if len(args_display) > 60:
2422
+ args_display = args_display[:60] + "…"
2423
+
2424
+ if args_display:
2425
+ print(colored(f" ⚡ {tool_name}", "cyan") + colored(f" {args_display}", "white", attrs=["dark"]), end="", flush=True)
2426
+ else:
2427
+ print(colored(f" ⚡ {tool_name}", "cyan"), end="", flush=True)
2428
+ except:
2429
+ pass
2430
+
2431
+ # Execute tool
2432
+ try:
2433
+ result = tool_func(**kwargs)
2434
+ if log_level != "silent":
2435
+ try:
2436
+ print(colored(" ✓", "green"), flush=True)
2437
+ # Show preview of result only in verbose mode
2438
+ if log_level == "verbose":
2439
+ result_preview = str(result)
2440
+ if len(result_preview) > 200:
2441
+ result_preview = result_preview[:200] + "..."
2442
+ if result_preview and result_preview not in ('None', '', '{}', '[]'):
2443
+ print(colored(f" → {result_preview}", "white", attrs=["dark"]), flush=True)
2444
+ except:
2445
+ pass
2446
+ return result
2447
+ except Exception as e:
2448
+ if log_level != "silent":
2449
+ try:
2450
+ print(colored(f" ✗ {str(e)[:100]}", "red"), flush=True)
2451
+ except:
2452
+ pass
2453
+ raise
2454
+ return wrapped
2455
+
2456
+
2126
2457
  def collect_llm_tools(state: ShellState) -> Tuple[List[Dict[str, Any]], Dict[str, Callable]]:
2127
2458
  """
2128
2459
  Assemble tool definitions + executable map from NPC tools, Jinxs, and MCP servers.
@@ -2230,7 +2561,11 @@ def collect_llm_tools(state: ShellState) -> Tuple[List[Dict[str, Any]], Dict[str
2230
2561
  name = tool_def.get("function", {}).get("name")
2231
2562
  if name:
2232
2563
  deduped[name] = tool_def
2233
- return list(deduped.values()), tool_map
2564
+
2565
+ # Wrap all tools with display feedback for npcsh
2566
+ wrapped_tool_map = {name: wrap_tool_with_display(name, func, state) for name, func in tool_map.items()}
2567
+
2568
+ return list(deduped.values()), wrapped_tool_map
2234
2569
 
2235
2570
 
2236
2571
  def should_skip_kg_processing(user_input: str, assistant_output: str) -> bool:
@@ -2454,15 +2789,19 @@ def process_pipeline_command(
2454
2789
  else cmd_to_process
2455
2790
  )
2456
2791
  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
- )
2792
+ if os.path.exists(state.current_path):
2793
+ all_files = os.listdir(state.current_path)
2794
+ # Limit to first 100 files to avoid token explosion
2795
+ limited_files = all_files[:100]
2796
+ file_list = "\n".join([
2797
+ os.path.join(state.current_path, f)
2798
+ for f in limited_files
2799
+ ])
2800
+ if len(all_files) > 100:
2801
+ file_list += f"\n... and {len(all_files) - 100} more files"
2802
+ ls_files = 'Files in the current directory (full paths):\n' + file_list
2803
+ else:
2804
+ ls_files = 'No files found in the current directory.'
2466
2805
  platform_info = (
2467
2806
  f"Platform: {platform.system()} {platform.release()} "
2468
2807
  f"({platform.machine()})"
@@ -2526,18 +2865,66 @@ def process_pipeline_command(
2526
2865
  (exec_model and "gemini" in exec_model.lower())
2527
2866
  llm_kwargs["tool_choice"] = 'auto'
2528
2867
 
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
- )
2868
+ # Agent loop: keep calling LLM until it stops making tool calls
2869
+ # The LLM decides when it's done - npcsh just facilitates
2870
+ iteration = 0
2871
+ max_iterations = 50 # Safety limit to prevent infinite loops
2872
+ total_usage = {"input_tokens": 0, "output_tokens": 0}
2873
+
2874
+ while iteration < max_iterations:
2875
+ iteration += 1
2876
+
2877
+ llm_result = get_llm_response(
2878
+ full_llm_cmd if iteration == 1 else None, # Only pass prompt on first call
2879
+ model=exec_model,
2880
+ provider=exec_provider,
2881
+ npc=state.npc,
2882
+ team=state.team,
2883
+ messages=state.messages,
2884
+ stream=False, # Don't stream intermediate calls
2885
+ attachments=state.attachments if iteration == 1 else None,
2886
+ context=info if iteration == 1 else None,
2887
+ **llm_kwargs,
2888
+ )
2889
+
2890
+ # Accumulate usage
2891
+ if isinstance(llm_result, dict) and llm_result.get('usage'):
2892
+ total_usage["input_tokens"] += llm_result['usage'].get('input_tokens', 0)
2893
+ total_usage["output_tokens"] += llm_result['usage'].get('output_tokens', 0)
2894
+
2895
+ # Update state messages
2896
+ old_msg_count = len(state.messages) if state.messages else 0
2897
+ if isinstance(llm_result, dict):
2898
+ state.messages = llm_result.get("messages", state.messages)
2899
+
2900
+ # Display tool outputs from this iteration
2901
+ for msg in state.messages[old_msg_count:]:
2902
+ if msg.get("role") == "tool":
2903
+ tool_name = msg.get("name", "tool")
2904
+ tool_content = msg.get("content", "")
2905
+ if tool_content and tool_content.strip():
2906
+ print(colored(f"\n⚡ {tool_name}:", "cyan"))
2907
+ lines = tool_content.split('\n')
2908
+ if len(lines) > 50:
2909
+ print('\n'.join(lines[:25]))
2910
+ print(colored(f"\n... ({len(lines) - 50} lines hidden) ...\n", "white", attrs=["dark"]))
2911
+ print('\n'.join(lines[-25:]))
2912
+ else:
2913
+ print(tool_content)
2914
+
2915
+ # Check if LLM made tool calls - if not, it's done
2916
+ tool_calls_made = isinstance(llm_result, dict) and llm_result.get("tool_calls")
2917
+ if not tool_calls_made:
2918
+ # LLM is done - no more tool calls
2919
+ break
2920
+
2921
+ # Clear the prompt for continuation calls - context is in messages
2922
+ full_llm_cmd = None
2923
+
2924
+ # Store accumulated usage
2925
+ if isinstance(llm_result, dict):
2926
+ llm_result['usage'] = total_usage
2927
+
2541
2928
  else:
2542
2929
  llm_result = check_llm_command(
2543
2930
  full_llm_cmd,
@@ -2781,6 +3168,11 @@ def execute_command(
2781
3168
  if not command.strip():
2782
3169
  return state, ""
2783
3170
 
3171
+ # Unescape @ and / at start of command that were escaped to prevent misinterpretation
3172
+ # (e.g., from pasted content that starts with @ or /)
3173
+ if command.startswith('\\@') or command.startswith('\\/'):
3174
+ command = command[1:] # Remove the escape backslash
3175
+
2784
3176
  # Check for mode switch commands
2785
3177
  mode_change, state = check_mode_switch(command, state)
2786
3178
  if mode_change:
@@ -2802,6 +3194,8 @@ def execute_command(
2802
3194
 
2803
3195
  original_command_for_embedding = command
2804
3196
  commands = split_by_pipes(command)
3197
+ # Unescape pipe characters that were escaped to prevent splitting (e.g., from pasted content)
3198
+ commands = [cmd.replace('\\|', '|') for cmd in commands]
2805
3199
 
2806
3200
  stdin_for_next = None
2807
3201
  final_output = None
@@ -2993,32 +3387,44 @@ def setup_shell() -> Tuple[CommandHistory, Team, Optional[NPC]]:
2993
3387
  default_forenpc_name = "forenpc"
2994
3388
  else:
2995
3389
  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()
3390
+ try:
3391
+ resp = input(f"No npc_team found in {os.getcwd()}. Create a new team here? [Y/n]: ").strip().lower()
3392
+ except (KeyboardInterrupt, EOFError):
3393
+ print("\nAborted.")
3394
+ sys.exit(0)
2997
3395
  if resp in ("", "y", "yes"):
2998
3396
  team_dir = project_team_path
2999
3397
  os.makedirs(team_dir, exist_ok=True)
3000
3398
  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
-
3399
+ try:
3400
+ forenpc_directive = input(
3401
+ f"Enter a primary directive for {default_forenpc_name} (default: 'You are the forenpc of the team...'): "
3402
+ ).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."
3403
+ forenpc_model = input("Enter a model for your forenpc (default: llama3.2): ").strip() or "llama3.2"
3404
+ forenpc_provider = input("Enter a provider for your forenpc (default: ollama): ").strip() or "ollama"
3405
+ except (KeyboardInterrupt, EOFError):
3406
+ print("\nAborted.")
3407
+ sys.exit(0)
3408
+
3007
3409
  with open(os.path.join(team_dir, f"{default_forenpc_name}.npc"), "w") as f:
3008
3410
  yaml.dump({
3009
3411
  "name": default_forenpc_name, "primary_directive": forenpc_directive,
3010
3412
  "model": forenpc_model, "provider": forenpc_provider
3011
3413
  }, f)
3012
-
3414
+
3013
3415
  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()
3416
+ try:
3417
+ folder_context = input("Enter a short description for this project/team (optional): ").strip()
3418
+ team_ctx_data = {
3419
+ "forenpc": default_forenpc_name,
3420
+ "model": forenpc_model,
3421
+ "provider": forenpc_provider,
3422
+ "context": folder_context if folder_context else None
3423
+ }
3424
+ use_jinxs = input("Use global jinxs folder (g) or copy to this project (c)? [g/c, default: g]: ").strip().lower()
3425
+ except (KeyboardInterrupt, EOFError):
3426
+ print("\nAborted.")
3427
+ sys.exit(0)
3022
3428
  if use_jinxs == "c":
3023
3429
  global_jinxs_dir = os.path.expanduser("~/.npcsh/npc_team/jinxs")
3024
3430
  if os.path.exists(global_jinxs_dir):
@@ -3035,7 +3441,7 @@ def setup_shell() -> Tuple[CommandHistory, Team, Optional[NPC]]:
3035
3441
  with open(".npcsh_global", "w") as f:
3036
3442
  pass
3037
3443
  team_dir = global_team_path
3038
- default_forenpc_name = "sibiji"
3444
+ default_forenpc_name = "sibiji"
3039
3445
  else:
3040
3446
  team_dir = global_team_path
3041
3447
  default_forenpc_name = "sibiji"
@@ -3195,7 +3601,8 @@ def process_result(
3195
3601
 
3196
3602
  # FIX: Handle dict output properly
3197
3603
  if isinstance(output, dict):
3198
- output_content = output.get('output')
3604
+ # Use None-safe check to not skip empty strings
3605
+ output_content = output.get('output') if 'output' in output else output.get('response')
3199
3606
  model_for_stream = output.get('model', active_npc.model)
3200
3607
  provider_for_stream = output.get('provider', active_npc.provider)
3201
3608
 
@@ -3212,18 +3619,22 @@ def process_result(
3212
3619
  usage.get('output_tokens', 0)
3213
3620
  )
3214
3621
 
3215
- # If output_content is still a dict or None, convert to string
3622
+ # If output_content is still a dict, convert to string
3216
3623
  if isinstance(output_content, dict):
3217
3624
  output_content = str(output_content)
3218
- elif output_content is None:
3219
- output_content = "Command completed with no output"
3625
+ elif output_content is None or output_content == '':
3626
+ # No output from the agent - this is fine, don't show annoying message
3627
+ output_content = None
3220
3628
  else:
3221
3629
  output_content = output
3222
3630
  model_for_stream = active_npc.model
3223
3631
  provider_for_stream = active_npc.provider
3224
3632
 
3225
3633
  print('\n')
3226
- if user_input == '/help':
3634
+ if output_content is None:
3635
+ # No output to display - tool results already shown during execution
3636
+ pass
3637
+ elif user_input == '/help':
3227
3638
  if isinstance(output_content, str):
3228
3639
  render_markdown(output_content)
3229
3640
  else:
@@ -3235,12 +3646,12 @@ def process_result(
3235
3646
  render_markdown(final_output_str)
3236
3647
  else:
3237
3648
  final_output_str = print_and_process_stream_with_markdown(
3238
- output_content,
3239
- model_for_stream,
3240
- provider_for_stream,
3649
+ output_content,
3650
+ model_for_stream,
3651
+ provider_for_stream,
3241
3652
  show=True
3242
3653
  )
3243
- elif output_content is not None:
3654
+ else:
3244
3655
  final_output_str = str(output_content)
3245
3656
  render_markdown(final_output_str)
3246
3657