npcsh 1.1.20__py3-none-any.whl → 1.1.22__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 (186) hide show
  1. npcsh/_state.py +15 -76
  2. npcsh/benchmark/npcsh_agent.py +22 -14
  3. npcsh/benchmark/templates/install-npcsh.sh.j2 +2 -2
  4. npcsh/diff_viewer.py +3 -3
  5. npcsh/mcp_server.py +9 -1
  6. npcsh/npc_team/alicanto.npc +12 -6
  7. npcsh/npc_team/corca.npc +0 -1
  8. npcsh/npc_team/frederic.npc +2 -3
  9. npcsh/npc_team/jinxs/lib/core/compress.jinx +373 -85
  10. npcsh/npc_team/jinxs/lib/core/edit_file.jinx +83 -61
  11. npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +17 -6
  12. npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +17 -6
  13. npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +52 -14
  14. npcsh/npc_team/jinxs/{bin → lib/utils}/benchmark.jinx +2 -2
  15. npcsh/npc_team/jinxs/{bin → lib/utils}/jinxs.jinx +12 -12
  16. npcsh/npc_team/jinxs/{bin → lib/utils}/models.jinx +7 -7
  17. npcsh/npc_team/jinxs/{bin → lib/utils}/setup.jinx +6 -6
  18. npcsh/npc_team/jinxs/modes/alicanto.jinx +1633 -295
  19. npcsh/npc_team/jinxs/modes/arxiv.jinx +5 -5
  20. npcsh/npc_team/jinxs/modes/build.jinx +378 -0
  21. npcsh/npc_team/jinxs/modes/config_tui.jinx +300 -0
  22. npcsh/npc_team/jinxs/modes/convene.jinx +597 -0
  23. npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
  24. npcsh/npc_team/jinxs/modes/git.jinx +795 -0
  25. {npcsh-1.1.20.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/modes}/kg.jinx +82 -15
  26. npcsh/npc_team/jinxs/modes/memories.jinx +414 -0
  27. npcsh/npc_team/jinxs/{bin → modes}/nql.jinx +10 -21
  28. npcsh/npc_team/jinxs/modes/papers.jinx +578 -0
  29. npcsh/npc_team/jinxs/modes/plonk.jinx +503 -308
  30. npcsh/npc_team/jinxs/modes/reattach.jinx +3 -3
  31. npcsh/npc_team/jinxs/modes/spool.jinx +3 -3
  32. npcsh/npc_team/jinxs/{bin → modes}/team.jinx +12 -12
  33. npcsh/npc_team/jinxs/modes/vixynt.jinx +388 -0
  34. npcsh/npc_team/jinxs/modes/wander.jinx +454 -181
  35. npcsh/npc_team/jinxs/modes/yap.jinx +630 -182
  36. npcsh/npc_team/kadiefa.npc +2 -1
  37. npcsh/npc_team/sibiji.npc +3 -3
  38. npcsh/npcsh.py +112 -47
  39. npcsh/routes.py +4 -1
  40. npcsh/salmon_simulation.py +0 -0
  41. npcsh-1.1.22.data/data/npcsh/npc_team/alicanto.jinx +1694 -0
  42. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.npc +12 -6
  43. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/arxiv.jinx +5 -5
  44. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/benchmark.jinx +2 -2
  45. npcsh-1.1.22.data/data/npcsh/npc_team/build.jinx +378 -0
  46. npcsh-1.1.22.data/data/npcsh/npc_team/compress.jinx +428 -0
  47. npcsh-1.1.22.data/data/npcsh/npc_team/config_tui.jinx +300 -0
  48. npcsh-1.1.22.data/data/npcsh/npc_team/corca.jinx +820 -0
  49. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.npc +0 -1
  50. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/db_search.jinx +17 -6
  51. npcsh-1.1.22.data/data/npcsh/npc_team/edit_file.jinx +119 -0
  52. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/file_search.jinx +17 -6
  53. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic.npc +2 -3
  54. npcsh-1.1.22.data/data/npcsh/npc_team/git.jinx +795 -0
  55. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/jinxs.jinx +12 -12
  56. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.npc +2 -1
  57. {npcsh/npc_team/jinxs/bin → npcsh-1.1.22.data/data/npcsh/npc_team}/kg.jinx +82 -15
  58. npcsh-1.1.22.data/data/npcsh/npc_team/memories.jinx +414 -0
  59. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/models.jinx +7 -7
  60. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/nql.jinx +10 -21
  61. npcsh-1.1.22.data/data/npcsh/npc_team/papers.jinx +578 -0
  62. npcsh-1.1.22.data/data/npcsh/npc_team/plonk.jinx +574 -0
  63. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/reattach.jinx +3 -3
  64. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/setup.jinx +6 -6
  65. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.npc +3 -3
  66. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.jinx +3 -3
  67. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/team.jinx +12 -12
  68. npcsh-1.1.22.data/data/npcsh/npc_team/vixynt.jinx +388 -0
  69. npcsh-1.1.22.data/data/npcsh/npc_team/wander.jinx +728 -0
  70. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/web_search.jinx +52 -14
  71. npcsh-1.1.22.data/data/npcsh/npc_team/yap.jinx +716 -0
  72. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/METADATA +246 -281
  73. npcsh-1.1.22.dist-info/RECORD +240 -0
  74. npcsh-1.1.22.dist-info/entry_points.txt +11 -0
  75. npcsh/npc_team/jinxs/bin/config_tui.jinx +0 -300
  76. npcsh/npc_team/jinxs/bin/memories.jinx +0 -317
  77. npcsh/npc_team/jinxs/bin/vixynt.jinx +0 -122
  78. npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +0 -418
  79. npcsh/npc_team/jinxs/lib/core/search/mem_review.jinx +0 -73
  80. npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +0 -388
  81. npcsh/npc_team/jinxs/lib/core/search.jinx +0 -54
  82. npcsh/npc_team/jinxs/lib/research/paper_search.jinx +0 -412
  83. npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +0 -386
  84. npcsh/npc_team/jinxs/lib/utils/build.jinx +0 -65
  85. npcsh/npc_team/plonkjr.npc +0 -23
  86. npcsh-1.1.20.data/data/npcsh/npc_team/alicanto.jinx +0 -356
  87. npcsh-1.1.20.data/data/npcsh/npc_team/build.jinx +0 -65
  88. npcsh-1.1.20.data/data/npcsh/npc_team/compress.jinx +0 -140
  89. npcsh-1.1.20.data/data/npcsh/npc_team/config_tui.jinx +0 -300
  90. npcsh-1.1.20.data/data/npcsh/npc_team/corca.jinx +0 -430
  91. npcsh-1.1.20.data/data/npcsh/npc_team/edit_file.jinx +0 -97
  92. npcsh-1.1.20.data/data/npcsh/npc_team/kg_search.jinx +0 -418
  93. npcsh-1.1.20.data/data/npcsh/npc_team/mem_review.jinx +0 -73
  94. npcsh-1.1.20.data/data/npcsh/npc_team/mem_search.jinx +0 -388
  95. npcsh-1.1.20.data/data/npcsh/npc_team/memories.jinx +0 -317
  96. npcsh-1.1.20.data/data/npcsh/npc_team/paper_search.jinx +0 -412
  97. npcsh-1.1.20.data/data/npcsh/npc_team/plonk.jinx +0 -379
  98. npcsh-1.1.20.data/data/npcsh/npc_team/plonkjr.npc +0 -23
  99. npcsh-1.1.20.data/data/npcsh/npc_team/search.jinx +0 -54
  100. npcsh-1.1.20.data/data/npcsh/npc_team/semantic_scholar.jinx +0 -386
  101. npcsh-1.1.20.data/data/npcsh/npc_team/vixynt.jinx +0 -122
  102. npcsh-1.1.20.data/data/npcsh/npc_team/wander.jinx +0 -455
  103. npcsh-1.1.20.data/data/npcsh/npc_team/yap.jinx +0 -268
  104. npcsh-1.1.20.dist-info/RECORD +0 -248
  105. npcsh-1.1.20.dist-info/entry_points.txt +0 -25
  106. /npcsh/npc_team/jinxs/lib/{orchestration → core}/convene.jinx +0 -0
  107. /npcsh/npc_team/jinxs/lib/{orchestration → core}/delegate.jinx +0 -0
  108. /npcsh/npc_team/jinxs/{bin → lib/core}/sample.jinx +0 -0
  109. /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
  110. /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
  111. /npcsh/npc_team/jinxs/{bin → lib/utils}/sync.jinx +0 -0
  112. /npcsh/npc_team/jinxs/{bin → modes}/roll.jinx +0 -0
  113. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
  114. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.png +0 -0
  115. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
  116. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
  117. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/chat.jinx +0 -0
  118. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/click.jinx +0 -0
  119. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
  120. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
  121. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
  122. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/cmd.jinx +0 -0
  123. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/compile.jinx +0 -0
  124. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/confirm.jinx +0 -0
  125. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/convene.jinx +0 -0
  126. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.png +0 -0
  127. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca_example.png +0 -0
  128. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/delegate.jinx +0 -0
  129. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
  130. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic4.png +0 -0
  131. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.jinx +0 -0
  132. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.npc +0 -0
  133. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.png +0 -0
  134. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/help.jinx +0 -0
  135. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/incognide.jinx +0 -0
  136. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/init.jinx +0 -0
  137. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.png +0 -0
  138. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/key_press.jinx +0 -0
  139. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
  140. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
  141. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/load_file.jinx +0 -0
  142. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/navigate.jinx +0 -0
  143. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/notify.jinx +0 -0
  144. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
  145. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
  146. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
  147. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
  148. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/ots.jinx +0 -0
  149. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/paste.jinx +0 -0
  150. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.npc +0 -0
  151. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.png +0 -0
  152. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonkjr.png +0 -0
  153. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/pti.jinx +0 -0
  154. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/python.jinx +0 -0
  155. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
  156. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/roll.jinx +0 -0
  157. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
  158. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sample.jinx +0 -0
  159. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
  160. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/send_message.jinx +0 -0
  161. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/serve.jinx +0 -0
  162. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/set.jinx +0 -0
  163. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sh.jinx +0 -0
  164. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/shh.jinx +0 -0
  165. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.png +0 -0
  166. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sleep.jinx +0 -0
  167. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
  168. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.png +0 -0
  169. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sql.jinx +0 -0
  170. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch.jinx +0 -0
  171. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
  172. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
  173. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switches.jinx +0 -0
  174. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sync.jinx +0 -0
  175. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
  176. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/trigger.jinx +0 -0
  177. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/type_text.jinx +0 -0
  178. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/usage.jinx +0 -0
  179. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/verbose.jinx +0 -0
  180. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/wait.jinx +0 -0
  181. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/write_file.jinx +0 -0
  182. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/yap.png +0 -0
  183. {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
  184. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/WHEEL +0 -0
  185. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/licenses/LICENSE +0 -0
  186. {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  jinx_name: corca
2
- description: MCP-powered agentic shell - LLM with tool use via MCP servers
2
+ description: MCP-powered agentic shell with tabbed TUI
3
+ interactive: true
3
4
  inputs:
4
5
  - mcp_server_path: null
5
6
  - initial_command: null
@@ -7,424 +8,813 @@ inputs:
7
8
  - provider: null
8
9
 
9
10
  steps:
10
- - name: corca_repl
11
+ - name: corca_tui
11
12
  engine: python
12
13
  code: |
13
- import os
14
- import sys
15
- import tty
16
- import termios
17
- import asyncio
18
- import json
14
+ import os, sys, tty, termios, asyncio, json, traceback, threading, time
15
+ import select as _sel
19
16
  from contextlib import AsyncExitStack
17
+ from pathlib import Path
20
18
  from termcolor import colored
21
19
 
22
20
  from npcpy.llm_funcs import get_llm_response
23
21
  from npcpy.npc_sysenv import render_markdown, get_system_message
24
22
 
25
- # MCP imports
23
+ try:
24
+ from litellm.exceptions import Timeout, ContextWindowExceededError, RateLimitError, BadRequestError
25
+ except ImportError:
26
+ Timeout = ContextWindowExceededError = RateLimitError = BadRequestError = Exception
27
+
26
28
  try:
27
29
  from mcp import ClientSession, StdioServerParameters
28
30
  from mcp.client.stdio import stdio_client
29
31
  MCP_AVAILABLE = True
30
32
  except ImportError:
31
33
  MCP_AVAILABLE = False
32
- print(colored("MCP not available. Install with: pip install mcp-client", "yellow"))
33
34
 
34
- npc = context.get('npc')
35
- team = context.get('team')
36
- messages = context.get('messages', [])
37
- mcp_server_path = context.get('mcp_server_path')
38
- initial_command = context.get('initial_command')
35
+ _npc = context.get('npc')
36
+ _team = context.get('team')
37
+ _messages = context.get('messages', [])
38
+ _mcp_path = context.get('mcp_server_path')
39
+ _init_cmd = context.get('initial_command')
40
+
41
+ if isinstance(_npc, str) and _team:
42
+ _npc = _team.get(_npc) if hasattr(_team, 'get') else None
43
+ elif isinstance(_npc, str):
44
+ _npc = None
45
+
46
+ # Always prefer the corca NPC
47
+ if _team and hasattr(_team, 'get'):
48
+ _corca = _team.get('corca')
49
+ if _corca:
50
+ _npc = _corca
51
+
52
+ _model = context.get('model') or (_npc.model if _npc and hasattr(_npc, 'model') else None)
53
+ _provider = context.get('provider') or (_npc.provider if _npc and hasattr(_npc, 'provider') else None)
54
+ _npc_name = _npc.name if _npc else "corca"
55
+
56
+ # ================================================================
57
+ # State
58
+ # ================================================================
59
+ class UI:
60
+ tab = 0 # 0=chat, 1=tools, 2=servers
61
+ TAB_NAMES = ['Chat', 'Tools', 'Servers']
62
+
63
+ # chat
64
+ chat_log = [] # [(role, text)] role: user/assistant/tool_call/tool_result/info/error
65
+ chat_scroll = -1 # -1 = auto-scroll to bottom
66
+ input_buf = ""
67
+ thinking = False
68
+ spinner_frame = 0
69
+ last_msg_idx = 0
70
+
71
+ # tools
72
+ tools_sel = 0
73
+ tools_scroll = 0
74
+ tools_mode = 'list'
75
+ preview_lines = []
76
+ preview_scroll = 0
77
+
78
+ # servers
79
+ srv_sel = 0
80
+ srv_adding = False
81
+ srv_buf = ""
82
+
83
+ ui = UI()
39
84
 
40
- # Resolve npc if it's a string (npc name) rather than NPC object
41
- if isinstance(npc, str) and team:
42
- npc = team.get(npc) if hasattr(team, 'get') else None
43
- elif isinstance(npc, str):
44
- npc = None
85
+ class MCP:
86
+ servers = []
87
+ tool_info = []
45
88
 
46
- model = context.get('model') or (npc.model if npc and hasattr(npc, 'model') else None)
47
- provider = context.get('provider') or (npc.provider if npc and hasattr(npc, 'provider') else None)
89
+ @staticmethod
90
+ def active_tools():
91
+ return [t['tool_def'] for t in MCP.tool_info if t['enabled']]
48
92
 
49
- # Use shared_context for MCP state
50
- shared_ctx = npc.shared_context if npc and hasattr(npc, 'shared_context') else {}
93
+ @staticmethod
94
+ def active_map():
95
+ return {t['name']: t['call'] for t in MCP.tool_info if t['enabled']}
51
96
 
52
- # ========== TUI Helper Functions ==========
53
- def get_terminal_size():
97
+ # ================================================================
98
+ # Helpers
99
+ # ================================================================
100
+ def sz():
54
101
  try:
55
- size = os.get_terminal_size()
56
- return size.columns, size.lines
102
+ s = os.get_terminal_size()
103
+ return s.columns, s.lines
57
104
  except:
58
105
  return 80, 24
59
106
 
60
- def tools_tui_browser(tools_llm):
61
- """Interactive TUI browser for MCP tools"""
62
- if not tools_llm:
63
- print(colored("No MCP tools connected.", "yellow"))
64
- return None
65
-
66
- # Build tool info list
67
- tools = []
68
- for t in tools_llm:
69
- func = t.get('function', {})
70
- tools.append({
71
- 'name': func.get('name', 'unknown'),
72
- 'description': func.get('description', '')[:100],
73
- 'params': func.get('parameters', {})
74
- })
75
-
76
- width, height = get_terminal_size()
77
- selected = 0
78
- scroll = 0
79
- list_height = height - 5
80
- mode = 'list'
81
- preview_scroll = 0
82
- preview_lines = []
83
-
84
- fd = sys.stdin.fileno()
85
- old_settings = termios.tcgetattr(fd)
86
-
107
+ def get_loop():
87
108
  try:
88
- tty.setcbreak(fd)
89
- sys.stdout.write('\033[?25l')
90
- sys.stdout.write('\033[2J\033[H')
91
-
92
- while True:
93
- width, height = get_terminal_size()
94
- list_height = height - 5
95
-
96
- if mode == 'list':
97
- if selected < scroll:
98
- scroll = selected
99
- elif selected >= scroll + list_height:
100
- scroll = selected - list_height + 1
101
-
102
- sys.stdout.write('\033[H')
103
-
104
- # Header
105
- if mode == 'list':
106
- header = f" CORCA MCP TOOLS ({len(tools)} available) "
107
- else:
108
- header = f" TOOL: {tools[selected]['name']} "
109
- sys.stdout.write(f'\033[46;30;1m{header.ljust(width)}\033[0m\n')
110
-
111
- if mode == 'list':
112
- col_header = f' {"NAME":<25} {"DESCRIPTION":<50}'
113
- sys.stdout.write(f'\033[90m{col_header[:width]}\033[0m\n')
109
+ lp = asyncio.get_event_loop()
110
+ if lp.is_closed():
111
+ lp = asyncio.new_event_loop()
112
+ asyncio.set_event_loop(lp)
113
+ return lp
114
+ except RuntimeError:
115
+ lp = asyncio.new_event_loop()
116
+ asyncio.set_event_loop(lp)
117
+ return lp
118
+
119
+ def clean_orphans(msgs):
120
+ out = []
121
+ for i, m in enumerate(msgs):
122
+ if m.get("role") == "tool":
123
+ ok = False
124
+ for j in range(i - 1, -1, -1):
125
+ p = msgs[j]
126
+ if p.get("role") == "assistant" and p.get("tool_calls"):
127
+ if m.get("tool_call_id") in {tc["id"] for tc in p["tool_calls"]}:
128
+ ok = True
129
+ break
130
+ elif p.get("role") in ("user", "assistant"):
131
+ break
132
+ if ok:
133
+ out.append(m)
134
+ elif m.get("role") == "assistant" and m.get("tool_calls"):
135
+ ids = {tc["id"] for tc in m["tool_calls"]}
136
+ found = set()
137
+ for j in range(i + 1, len(msgs)):
138
+ n = msgs[j]
139
+ if n.get("role") == "tool" and n.get("tool_call_id") in ids:
140
+ found.add(n["tool_call_id"])
141
+ elif n.get("role") in ("user", "assistant"):
142
+ break
143
+ miss = ids - found
144
+ if miss:
145
+ c = dict(m)
146
+ c["tool_calls"] = [tc for tc in m["tool_calls"] if tc["id"] not in miss]
147
+ if not c["tool_calls"]:
148
+ del c["tool_calls"]
149
+ out.append(c)
114
150
  else:
115
- sys.stdout.write(f'\033[90m{"─" * width}\033[0m\n')
116
-
117
- if mode == 'list':
118
- for i in range(list_height):
119
- idx = scroll + i
120
- sys.stdout.write(f'\033[{3+i};1H\033[K')
121
- if idx >= len(tools):
122
- continue
123
-
124
- t = tools[idx]
125
- name = t['name'][:25]
126
- desc = t['description'][:50]
127
-
128
- line = f" {name:<25} {desc}"
129
- line = line[:width-1]
130
-
131
- if idx == selected:
132
- sys.stdout.write(f'\033[7;1m>{line}\033[0m')
133
- else:
134
- sys.stdout.write(f' {line}')
135
-
136
- # Status bar
137
- sys.stdout.write(f'\033[{height-2};1H\033[K\033[90m{"" * width}\033[0m')
138
- t = tools[selected] if tools else {}
139
- params = t.get('params', {}).get('properties', {})
140
- param_names = list(params.keys())[:5]
141
- sys.stdout.write(f'\033[{height-1};1H\033[K Params: {", ".join(param_names) if param_names else "none"}'.ljust(width)[:width])
142
- sys.stdout.write(f'\033[{height};1H\033[K\033[46;30m j/k:Nav p:Details Enter:Copy q:Quit [{selected+1}/{len(tools)}] \033[0m')
143
-
144
- else: # preview mode
145
- for i in range(list_height):
146
- idx = preview_scroll + i
147
- sys.stdout.write(f'\033[{3+i};1H\033[K')
148
- if idx < len(preview_lines):
149
- sys.stdout.write(preview_lines[idx][:width-1])
150
-
151
- sys.stdout.write(f'\033[{height-2};1H\033[K\033[90m{"─" * width}\033[0m')
152
- sys.stdout.write(f'\033[{height-1};1H\033[K [{preview_scroll+1}/{len(preview_lines)} lines]')
153
- sys.stdout.write(f'\033[{height};1H\033[K\033[46;30m j/k:Scroll b:Back q:Quit \033[0m')
154
-
155
- sys.stdout.flush()
156
-
157
- c = sys.stdin.read(1)
158
-
159
- if c == '\x1b':
160
- c2 = sys.stdin.read(1)
161
- if c2 == '[':
162
- c3 = sys.stdin.read(1)
163
- if c3 == 'A': # Up
164
- if mode == 'list' and selected > 0:
165
- selected -= 1
166
- elif mode == 'preview' and preview_scroll > 0:
167
- preview_scroll -= 1
168
- elif c3 == 'B': # Down
169
- if mode == 'list' and selected < len(tools) - 1:
170
- selected += 1
171
- elif mode == 'preview' and preview_scroll < max(0, len(preview_lines) - list_height):
172
- preview_scroll += 1
173
- else:
174
- if mode == 'preview':
175
- mode = 'list'
176
- sys.stdout.write('\033[2J\033[H')
177
- else:
178
- return None
179
- continue
180
-
181
- if c == 'q' or c == '\x03':
182
- return None
183
- elif c == 'k':
184
- if mode == 'list' and selected > 0:
185
- selected -= 1
186
- elif mode == 'preview' and preview_scroll > 0:
187
- preview_scroll -= 1
188
- elif c == 'j':
189
- if mode == 'list' and selected < len(tools) - 1:
190
- selected += 1
191
- elif mode == 'preview' and preview_scroll < max(0, len(preview_lines) - list_height):
192
- preview_scroll += 1
193
- elif c == 'p' and mode == 'list' and tools:
194
- # Preview tool details
195
- t = tools[selected]
196
- preview_str = f"Tool: {t['name']}\n"
197
- preview_str += f"{'=' * 40}\n\n"
198
- preview_str += f"Description:\n{t['description']}\n\n"
199
- preview_str += f"Parameters:\n"
200
- params = t.get('params', {})
201
- props = params.get('properties', {})
202
- required = params.get('required', [])
203
- for pname, pinfo in props.items():
204
- req = "*" if pname in required else ""
205
- ptype = pinfo.get('type', 'any')
206
- pdesc = pinfo.get('description', '')[:60]
207
- preview_str += f" {pname}{req} ({ptype}): {pdesc}\n"
208
- if not props:
209
- preview_str += " (no parameters)\n"
210
- preview_lines = preview_str.split('\n')
211
- mode = 'preview'
212
- preview_scroll = 0
213
- sys.stdout.write('\033[2J\033[H')
214
- elif c == 'b' and mode == 'preview':
215
- mode = 'list'
216
- sys.stdout.write('\033[2J\033[H')
217
- elif c in ('\r', '\n') and mode == 'list' and tools:
218
- # Return tool name for use
219
- return tools[selected]['name']
220
-
221
- finally:
222
- termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
223
- sys.stdout.write('\033[?25h')
224
- sys.stdout.write('\033[2J\033[H')
225
- sys.stdout.flush()
226
-
227
- print("""
228
- ██████╗ ██████╗ ██████╗ ██████╗ █████╗
229
- ██╔════╝██╔═══██╗██╔══██╗██╔════╝██╔══██╗
230
- ██║ ██║ ██║██████╔╝██║ ███████║
231
- ██║ ██║ ██║██╔══██╗██║ ██╔══██╗
232
- ╚██████╗╚██████╔╝██║ ██║╚██████╗██║ ██║
233
- ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
234
- """)
235
-
236
- npc_name = npc.name if npc else "corca"
237
- print(f"Entering corca mode (NPC: {npc_name}). Type '/cq' to exit.")
238
-
239
- # ========== MCP Connection Setup ==========
151
+ out.append(m)
152
+ else:
153
+ out.append(m)
154
+ return out
155
+
156
+ def llm_call(prompt, msgs):
157
+ tools = MCP.active_tools() or None
158
+ tmap = MCP.active_map() or None
159
+ msgs = clean_orphans(msgs)
160
+ try:
161
+ return get_llm_response(
162
+ prompt, npc=_npc, messages=msgs,
163
+ tools=tools, tool_map=tmap,
164
+ auto_process_tool_calls=True,
165
+ stream=False, team=_team,
166
+ context=f'Working directory: {os.getcwd()}'
167
+ )
168
+ except ContextWindowExceededError:
169
+ if _npc and hasattr(_npc, 'compress_planning_state'):
170
+ c = _npc.compress_planning_state(msgs)
171
+ return get_llm_response(
172
+ prompt, npc=_npc,
173
+ messages=[{"role": "system", "content": c}],
174
+ tools=tools, tool_map=tmap,
175
+ auto_process_tool_calls=True, stream=False, team=_team,
176
+ )
177
+ raise
178
+ except RateLimitError:
179
+ time.sleep(60)
180
+ return get_llm_response(
181
+ prompt, npc=_npc, messages=msgs,
182
+ tools=tools, tool_map=tmap,
183
+ auto_process_tool_calls=True, stream=False, team=_team,
184
+ )
185
+ except BadRequestError as e:
186
+ if "tool_call_id" in str(e).lower():
187
+ return get_llm_response(
188
+ prompt, npc=_npc, messages=clean_orphans(msgs),
189
+ tools=tools, tool_map=tmap,
190
+ auto_process_tool_calls=True, stream=False, team=_team,
191
+ )
192
+ raise
193
+
194
+ # ================================================================
195
+ # MCP
196
+ # ================================================================
240
197
  async def connect_mcp(server_path):
241
- """Connect to MCP server and return tools"""
242
198
  if not MCP_AVAILABLE:
243
- return [], {}
244
-
199
+ ui.chat_log.append(('error', 'MCP not available. pip install mcp-client'))
200
+ return False
245
201
  abs_path = os.path.abspath(os.path.expanduser(server_path))
246
202
  if not os.path.exists(abs_path):
247
- print(colored(f"MCP server not found: {abs_path}", "red"))
248
- return [], {}
249
-
203
+ ui.chat_log.append(('error', f'MCP server not found: {abs_path}'))
204
+ return False
205
+ for s in MCP.servers:
206
+ if s['path'] == abs_path and s['connected']:
207
+ return True
208
+
209
+ lp = get_loop()
210
+ es = AsyncExitStack()
211
+ cmd = [sys.executable, abs_path] if abs_path.endswith('.py') else [abs_path]
212
+ sp = StdioServerParameters(command=cmd[0], args=[abs_path], env=os.environ.copy(), cwd=Path(abs_path).parent)
250
213
  try:
251
- loop = asyncio.get_event_loop()
252
- except RuntimeError:
253
- loop = asyncio.new_event_loop()
254
- asyncio.set_event_loop(loop)
255
-
256
- exit_stack = AsyncExitStack()
257
-
258
- if abs_path.endswith('.py'):
259
- cmd_parts = [sys.executable, abs_path]
214
+ st = await es.enter_async_context(stdio_client(sp))
215
+ sess = await es.enter_async_context(ClientSession(*st))
216
+ await sess.initialize()
217
+ resp = await sess.list_tools()
218
+ except Exception as e:
219
+ ui.chat_log.append(('error', f'MCP connect failed: {e}'))
220
+ return False
221
+
222
+ sidx = len(MCP.servers)
223
+ MCP.servers.append({'path': abs_path, 'session': sess, 'exit_stack': es, 'loop': lp, 'connected': True})
224
+
225
+ if resp.tools:
226
+ for mt in resp.tools:
227
+ td = {"type": "function", "function": {
228
+ "name": mt.name,
229
+ "description": mt.description or f"MCP tool: {mt.name}",
230
+ "parameters": getattr(mt, "inputSchema", {"type": "object", "properties": {}})
231
+ }}
232
+ def mkf(tn, se, lo):
233
+ async def _call(**kw):
234
+ cl = {k: (None if v == 'None' else v) for k, v in kw.items()}
235
+ r = await asyncio.wait_for(se.call_tool(tn, cl), timeout=30.0)
236
+ if hasattr(r, 'content') and r.content:
237
+ parts = []
238
+ for it in r.content:
239
+ if hasattr(it, 'text'): parts.append(it.text)
240
+ elif hasattr(it, 'data'): parts.append(str(it.data))
241
+ else: parts.append(str(it))
242
+ return '\n'.join(parts)
243
+ return str(r)
244
+ def _sync(**kw):
245
+ return lo.run_until_complete(_call(**kw))
246
+ return _sync
247
+ MCP.tool_info.append({
248
+ 'name': mt.name, 'desc': (mt.description or '')[:80],
249
+ 'params': getattr(mt, "inputSchema", {}),
250
+ 'server_idx': sidx, 'enabled': True,
251
+ 'tool_def': td, 'call': mkf(mt.name, sess, lp),
252
+ })
253
+ n = sum(1 for t in MCP.tool_info if t['server_idx'] == sidx)
254
+ ui.chat_log.append(('info', f'Connected to {os.path.basename(abs_path)}. {n} tools.'))
255
+ return True
256
+
257
+ async def disconnect_srv(idx):
258
+ if idx < 0 or idx >= len(MCP.servers): return
259
+ s = MCP.servers[idx]
260
+ if not s['connected']: return
261
+ try: await s['exit_stack'].aclose()
262
+ except: pass
263
+ s['connected'] = False
264
+ for t in MCP.tool_info:
265
+ if t['server_idx'] == idx: t['enabled'] = False
266
+ ui.chat_log.append(('info', f'Disconnected from {os.path.basename(s["path"])}'))
267
+
268
+ # ================================================================
269
+ # Chat send
270
+ # ================================================================
271
+ def send_message(text):
272
+ ui.chat_log.append(('user', text))
273
+ ui.thinking = True
274
+ ui.chat_scroll = -1
275
+ ui.last_msg_idx = len(_messages)
276
+
277
+ def worker():
278
+ try:
279
+ resp = llm_call(text, _messages)
280
+ _messages[:] = resp.get('messages', _messages)
281
+ # Extract new messages for display
282
+ new = _messages[ui.last_msg_idx:]
283
+ for m in new:
284
+ role = m.get('role', '')
285
+ if role == 'user':
286
+ pass # already added
287
+ elif role == 'assistant':
288
+ tcs = m.get('tool_calls', [])
289
+ for tc in tcs:
290
+ fn = tc.get('function', {})
291
+ nm = fn.get('name', '?')
292
+ args = fn.get('arguments', '')
293
+ if isinstance(args, str):
294
+ try: args = json.loads(args)
295
+ except: pass
296
+ if isinstance(args, dict):
297
+ brief = ', '.join(f'{k}={repr(v)[:30]}' for k, v in list(args.items())[:3])
298
+ else:
299
+ brief = str(args)[:60]
300
+ ui.chat_log.append(('tool_call', f'{nm}({brief})'))
301
+ content = m.get('content', '')
302
+ if content:
303
+ ui.chat_log.append(('assistant', str(content)))
304
+ elif role == 'tool':
305
+ content = m.get('content', '')
306
+ name = m.get('name', '')
307
+ preview = content[:200].replace('\n', ' ')
308
+ ui.chat_log.append(('tool_result', f'{name}: {preview}'))
309
+
310
+ if _npc and hasattr(_npc, 'shared_context') and 'usage' in resp:
311
+ u = resp['usage']
312
+ _npc.shared_context['session_input_tokens'] += u.get('input_tokens', 0)
313
+ _npc.shared_context['session_output_tokens'] += u.get('output_tokens', 0)
314
+ _npc.shared_context['turn_count'] += 1
315
+ except Exception as e:
316
+ ui.chat_log.append(('error', str(e)))
317
+ ui.thinking = False
318
+ ui.last_msg_idx = len(_messages)
319
+
320
+ threading.Thread(target=worker, daemon=True).start()
321
+
322
+ # ================================================================
323
+ # Rendering
324
+ # ================================================================
325
+ TURQ = '\033[38;2;64;224;208m'
326
+ CHROME = '\033[38;2;211;211;211m'
327
+ ORANGE = '\033[38;2;255;165;0m'
328
+ DIM = '\033[90m'
329
+ BOLD = '\033[1m'
330
+ REV = '\033[7m'
331
+ RST = '\033[0m'
332
+ SPINNERS = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
333
+
334
+ def render():
335
+ w, h = sz()
336
+ buf = ['\033[H']
337
+
338
+ # Tab bar
339
+ tabs = ''
340
+ for i, name in enumerate(ui.TAB_NAMES):
341
+ if i == ui.tab:
342
+ tabs += f' {REV}{BOLD} {name} {RST} '
343
+ else:
344
+ tabs += f' {DIM} {name} {RST} '
345
+ en = sum(1 for t in MCP.tool_info if t['enabled'])
346
+ tot = len(MCP.tool_info)
347
+ srv_n = sum(1 for s in MCP.servers if s['connected'])
348
+ right = f'{_npc_name} | {_model or "?"}@{_provider or "?"} | {srv_n} srv | {en}/{tot} tools'
349
+ pad = w - len(ui.TAB_NAMES[0]) * 3 - 12 - len(right)
350
+ header = f'{TURQ}CORCA{RST} {tabs}{" " * max(0, pad)}{DIM}{right}{RST}'
351
+ buf.append(f'\033[1;1H{REV} {header[:w-2].ljust(w-2)} {RST}')
352
+
353
+ if ui.tab == 0:
354
+ render_chat(buf, w, h)
355
+ elif ui.tab == 1:
356
+ render_tools(buf, w, h)
357
+ elif ui.tab == 2:
358
+ render_servers(buf, w, h)
359
+
360
+ sys.stdout.write(''.join(buf))
361
+ sys.stdout.flush()
362
+
363
+ def wrap_text(text, width):
364
+ lines = []
365
+ for line in text.split('\n'):
366
+ while len(line) > width:
367
+ lines.append(line[:width])
368
+ line = line[width:]
369
+ lines.append(line)
370
+ return lines
371
+
372
+ def render_chat(buf, w, h):
373
+ input_h = 3 # divider + input + status
374
+ chat_h = h - 2 - input_h # -2 for header
375
+
376
+ # Format chat lines
377
+ all_lines = []
378
+ _asst_pw = len(_npc_name) + 2 # "name: "
379
+ for role, text in ui.chat_log:
380
+ if role == 'user':
381
+ tw = w - 6
382
+ wrapped = wrap_text(text, tw)
383
+ for i, l in enumerate(wrapped):
384
+ prefix = f'{BOLD}you:{RST} ' if i == 0 else ' '
385
+ all_lines.append(f'{prefix}{l}')
386
+ elif role == 'assistant':
387
+ tw = w - _asst_pw - 1
388
+ wrapped = wrap_text(text, tw)
389
+ pad = ' ' * _asst_pw
390
+ for i, l in enumerate(wrapped):
391
+ prefix = f'{TURQ}{BOLD}{_npc_name}:{RST} ' if i == 0 else pad
392
+ all_lines.append(f'{prefix}{l}')
393
+ elif role == 'tool_call':
394
+ tw = w - 5
395
+ wrapped = wrap_text(text, tw)
396
+ for i, l in enumerate(wrapped):
397
+ prefix = f' {ORANGE}⚡ ' if i == 0 else ' '
398
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
399
+ elif role == 'tool_result':
400
+ tw = w - 5
401
+ wrapped = wrap_text(text, tw)
402
+ for i, l in enumerate(wrapped):
403
+ prefix = f' {DIM}→ ' if i == 0 else ' '
404
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
405
+ elif role == 'info':
406
+ tw = w - 5
407
+ wrapped = wrap_text(text, tw)
408
+ for i, l in enumerate(wrapped):
409
+ prefix = f' {TURQ}ℹ ' if i == 0 else ' '
410
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
411
+ elif role == 'error':
412
+ tw = w - 5
413
+ wrapped = wrap_text(text, tw)
414
+ for i, l in enumerate(wrapped):
415
+ prefix = f' \033[31m✗ ' if i == 0 else ' '
416
+ all_lines.append(f'{prefix}{l}{RST}' if i == 0 else f' {l}')
417
+
418
+ if ui.thinking:
419
+ sp = SPINNERS[ui.spinner_frame % len(SPINNERS)]
420
+ all_lines.append(f' {ORANGE}{sp} thinking...{RST}')
421
+
422
+ # Scrolling
423
+ if ui.chat_scroll == -1:
424
+ scroll = max(0, len(all_lines) - chat_h)
260
425
  else:
261
- cmd_parts = [abs_path]
262
-
263
- server_params = StdioServerParameters(
264
- command=cmd_parts[0],
265
- args=[abs_path],
266
- env=os.environ.copy()
267
- )
268
-
269
- stdio_transport = await exit_stack.enter_async_context(stdio_client(server_params))
270
- session = await exit_stack.enter_async_context(ClientSession(*stdio_transport))
271
- await session.initialize()
272
-
273
- response = await session.list_tools()
274
- tools_llm = []
275
- tool_map = {}
276
-
277
- if response.tools:
278
- for mcp_tool in response.tools:
279
- tool_def = {
280
- "type": "function",
281
- "function": {
282
- "name": mcp_tool.name,
283
- "description": mcp_tool.description or f"MCP tool: {mcp_tool.name}",
284
- "parameters": getattr(mcp_tool, "inputSchema", {"type": "object", "properties": {}})
285
- }
286
- }
287
- tools_llm.append(tool_def)
288
-
289
- # Create sync wrapper for async tool call
290
- def make_tool_func(tool_name, sess, lp):
291
- async def call_tool(**kwargs):
292
- cleaned = {k: (None if v == 'None' else v) for k, v in kwargs.items()}
293
- result = await asyncio.wait_for(sess.call_tool(tool_name, cleaned), timeout=30.0)
294
- return result
295
- def sync_call(**kwargs):
296
- return lp.run_until_complete(call_tool(**kwargs))
297
- return sync_call
298
-
299
- tool_map[mcp_tool.name] = make_tool_func(mcp_tool.name, session, loop)
300
-
301
- # Store in shared context
302
- shared_ctx['mcp_client'] = session
303
- shared_ctx['mcp_tools'] = tools_llm
304
- shared_ctx['mcp_tool_map'] = tool_map
305
- shared_ctx['_mcp_exit_stack'] = exit_stack
306
- shared_ctx['_mcp_loop'] = loop
307
-
308
- print(colored(f"Connected to MCP server. Tools: {', '.join(tool_map.keys())}", "green"))
309
- return tools_llm, tool_map
310
-
311
- # Try to connect if server path provided
312
- tools_llm = shared_ctx.get('mcp_tools', [])
313
- tool_map = shared_ctx.get('mcp_tool_map', {})
314
-
315
- if mcp_server_path and not tools_llm:
316
- try:
317
- loop = asyncio.get_event_loop()
318
- except RuntimeError:
319
- loop = asyncio.new_event_loop()
320
- asyncio.set_event_loop(loop)
321
- tools_llm, tool_map = loop.run_until_complete(connect_mcp(mcp_server_path))
322
-
323
- # Find default MCP server if none provided
324
- if not tools_llm:
325
- default_paths = [
326
- os.path.expanduser("~/.npcsh/npc_team/mcp_server.py"),
327
- os.path.join(team.team_path, "mcp_server.py") if team and hasattr(team, 'team_path') else None,
328
- ]
329
- for path in default_paths:
330
- if path and os.path.exists(path):
426
+ scroll = ui.chat_scroll
427
+
428
+ for i in range(chat_h):
429
+ y = 2 + i
430
+ li = scroll + i
431
+ buf.append(f'\033[{y};1H\033[K')
432
+ if li < len(all_lines):
433
+ buf.append(all_lines[li])
434
+
435
+ # Input area
436
+ div_y = 2 + chat_h
437
+ buf.append(f'\033[{div_y};1H\033[K{DIM}{"─" * w}{RST}')
438
+ input_y = div_y + 1
439
+ visible_input = ui.input_buf[-(w - 4):] if len(ui.input_buf) > w - 4 else ui.input_buf
440
+ buf.append(f'\033[{input_y};1H\033[K {BOLD}>{RST} {visible_input}\033[?25h')
441
+
442
+ # Status
443
+ status_y = h
444
+ hints = f'Tab:Switch Enter:Send PgUp/PgDn:Scroll Ctrl+Q:Quit'
445
+ buf.append(f'\033[{status_y};1H\033[K{REV} {hints[:w-2].ljust(w-2)} {RST}')
446
+
447
+ def render_tools(buf, w, h):
448
+ list_h = h - 4
449
+ en = sum(1 for t in MCP.tool_info if t['enabled'])
450
+
451
+ if ui.tools_mode == 'list':
452
+ buf.append(f'\033[2;1H\033[K{DIM} {"":1} {"NAME":<25} {"SERVER":<20} {"DESCRIPTION"}{RST}')
453
+
454
+ if ui.tools_sel < ui.tools_scroll:
455
+ ui.tools_scroll = ui.tools_sel
456
+ elif ui.tools_sel >= ui.tools_scroll + list_h:
457
+ ui.tools_scroll = ui.tools_sel - list_h + 1
458
+
459
+ for i in range(list_h):
460
+ idx = ui.tools_scroll + i
461
+ y = 3 + i
462
+ buf.append(f'\033[{y};1H\033[K')
463
+ if idx >= len(MCP.tool_info):
464
+ continue
465
+ t = MCP.tool_info[idx]
466
+ ck = f'\033[32m✓{RST}' if t['enabled'] else f'{DIM}·{RST}'
467
+ srv = os.path.basename(MCP.servers[t['server_idx']]['path'])[:18] if t['server_idx'] < len(MCP.servers) else '?'
468
+ line = f' {ck} {t["name"][:25]:<25} {DIM}{srv[:20]:<20}{RST} {t["desc"][:w-52]}'
469
+ if idx == ui.tools_sel:
470
+ buf.append(f'{REV}{line[:w]}{RST}')
471
+ else:
472
+ buf.append(line[:w])
473
+
474
+ if not MCP.tool_info:
475
+ buf.append(f'\033[4;3H{DIM}No tools. Connect a server first.{RST}')
476
+
477
+ buf.append(f'\033[{h};1H\033[K{REV} j/k:Nav Space:Toggle a:All n:None p:Details Tab:Switch [{en}/{len(MCP.tool_info)}] {RST}')
478
+
479
+ else: # preview
480
+ for i in range(list_h + 1):
481
+ y = 2 + i
482
+ pi = ui.preview_scroll + i
483
+ buf.append(f'\033[{y};1H\033[K')
484
+ if pi < len(ui.preview_lines):
485
+ buf.append(ui.preview_lines[pi][:w - 1])
486
+ buf.append(f'\033[{h};1H\033[K{REV} j/k:Scroll Esc/b:Back {RST}')
487
+
488
+ def render_servers(buf, w, h):
489
+ if ui.srv_adding:
490
+ buf.append(f'\033[3;2H{BOLD}Server script path:{RST}')
491
+ buf.append(f'\033[5;2H{REV} {ui.srv_buf}_ {RST}')
492
+ for y in range(6, h - 1):
493
+ buf.append(f'\033[{y};1H\033[K')
494
+ buf.append(f'\033[{h};1H\033[K{REV} Enter:Connect Esc:Cancel {RST}')
495
+ else:
496
+ buf.append(f'\033[2;1H\033[K{DIM} {"STATUS":<14} {"PATH":<45} {"TOOLS"}{RST}')
497
+ for i, s in enumerate(MCP.servers):
498
+ y = 3 + i
499
+ if y >= h - 2: break
500
+ st = f'\033[32m● connected{RST}' if s['connected'] else f'\033[31m● disconnected{RST}'
501
+ tc = sum(1 for t in MCP.tool_info if t['server_idx'] == i and t['enabled'])
502
+ tt = sum(1 for t in MCP.tool_info if t['server_idx'] == i)
503
+ line = f' {st:<26} {s["path"][:43]:<45} {tc}/{tt}'
504
+ buf.append(f'\033[{y};1H\033[K')
505
+ if i == ui.srv_sel:
506
+ buf.append(f'{REV}{line[:w]}{RST}')
507
+ else:
508
+ buf.append(line[:w])
509
+ for y in range(3 + len(MCP.servers), h - 1):
510
+ buf.append(f'\033[{y};1H\033[K')
511
+ if not MCP.servers:
512
+ buf.append(f'\033[4;3H{DIM}No servers. Press a to add one.{RST}')
513
+ buf.append(f'\033[{h};1H\033[K{REV} a:Add d:Disconnect r:Reconnect Tab:Switch {RST}')
514
+
515
+ # ================================================================
516
+ # Input handling
517
+ # ================================================================
518
+ def handle_key(c, fd):
519
+ # Tab key: switch tabs
520
+ if c == '\t':
521
+ ui.tab = (ui.tab + 1) % 3
522
+ return True
523
+ # Ctrl+Q or Ctrl+C on non-chat
524
+ if c == '\x11': # Ctrl+Q
525
+ return False
526
+ if c == '\x03': # Ctrl+C
527
+ if ui.tab == 0 and ui.thinking:
528
+ ui.chat_log.append(('info', 'Interrupted.'))
529
+ return True
530
+ if ui.tab == 0:
531
+ return True # ignore in chat
532
+ return True
533
+
534
+ # Escape sequences
535
+ if c == '\x1b':
536
+ if _sel.select([fd], [], [], 0.05)[0]:
537
+ c2 = os.read(fd, 1).decode('latin-1')
538
+ if c2 == '[':
539
+ c3 = os.read(fd, 1).decode('latin-1')
540
+ # Arrow keys
541
+ if c3 == 'A': # Up
542
+ if ui.tab == 0:
543
+ _chat_scroll_up()
544
+ elif ui.tab == 1:
545
+ if ui.tools_mode == 'list' and ui.tools_sel > 0: ui.tools_sel -= 1
546
+ elif ui.tools_mode == 'preview' and ui.preview_scroll > 0: ui.preview_scroll -= 1
547
+ elif ui.tab == 2 and ui.srv_sel > 0:
548
+ ui.srv_sel -= 1
549
+ elif c3 == 'B': # Down
550
+ if ui.tab == 0:
551
+ _chat_scroll_down()
552
+ elif ui.tab == 1:
553
+ if ui.tools_mode == 'list' and ui.tools_sel < len(MCP.tool_info) - 1: ui.tools_sel += 1
554
+ elif ui.tools_mode == 'preview': ui.preview_scroll += 1
555
+ elif ui.tab == 2 and ui.srv_sel < len(MCP.servers) - 1:
556
+ ui.srv_sel += 1
557
+ elif c3 == '5': # PgUp
558
+ os.read(fd, 1) # consume ~
559
+ if ui.tab == 0: _chat_page_up()
560
+ elif c3 == '6': # PgDn
561
+ os.read(fd, 1) # consume ~
562
+ if ui.tab == 0: _chat_page_down()
563
+ elif c2 == 'O':
564
+ c3 = os.read(fd, 1).decode('latin-1')
565
+ if c3 == 'P': ui.tab = 0 # F1
566
+ elif c3 == 'Q': ui.tab = 1 # F2
567
+ elif c3 == 'R': ui.tab = 2 # F3
568
+ else:
569
+ # bare Esc
570
+ if ui.tab == 1 and ui.tools_mode == 'preview':
571
+ ui.tools_mode = 'list'
572
+ elif ui.tab == 2 and ui.srv_adding:
573
+ ui.srv_adding = False
574
+ ui.srv_buf = ""
575
+ else:
576
+ # bare Esc
577
+ if ui.tab == 1 and ui.tools_mode == 'preview':
578
+ ui.tools_mode = 'list'
579
+ elif ui.tab == 2 and ui.srv_adding:
580
+ ui.srv_adding = False
581
+ ui.srv_buf = ""
582
+ return True
583
+
584
+ # Dispatch to tab handler
585
+ if ui.tab == 0:
586
+ return handle_chat(c, fd)
587
+ elif ui.tab == 1:
588
+ return handle_tools(c, fd)
589
+ elif ui.tab == 2:
590
+ return handle_servers(c, fd)
591
+ return True
592
+
593
+ def _chat_scroll_up():
594
+ _, h = sz()
595
+ chat_h = h - 5
596
+ if ui.chat_scroll == -1:
597
+ ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - 1)
598
+ ui.chat_scroll = max(0, ui.chat_scroll - 1)
599
+
600
+ def _chat_scroll_down():
601
+ ui.chat_scroll = -1 if ui.chat_scroll == -1 else ui.chat_scroll + 1
602
+
603
+ def _chat_page_up():
604
+ _, h = sz()
605
+ chat_h = h - 5
606
+ if ui.chat_scroll == -1:
607
+ ui.chat_scroll = max(0, len(ui.chat_log) * 2 - chat_h - chat_h)
608
+ else:
609
+ ui.chat_scroll = max(0, ui.chat_scroll - chat_h)
610
+
611
+ def _chat_page_down():
612
+ ui.chat_scroll = -1
613
+
614
+ def handle_chat(c, fd):
615
+ if ui.thinking:
616
+ return True # ignore input while thinking
617
+
618
+ if c in ('\r', '\n'):
619
+ text = ui.input_buf.strip()
620
+ ui.input_buf = ""
621
+ if text:
622
+ send_message(text)
623
+ return True
624
+
625
+ if c == '\x7f' or c == '\x08': # Backspace
626
+ ui.input_buf = ui.input_buf[:-1]
627
+ return True
628
+
629
+ if c >= ' ' and c <= '~':
630
+ ui.input_buf += c
631
+ ui.chat_scroll = -1
632
+ return True
633
+
634
+ return True
635
+
636
+ def handle_tools(c, fd):
637
+ if ui.tools_mode == 'preview':
638
+ if c == 'j': ui.preview_scroll += 1
639
+ elif c == 'k' and ui.preview_scroll > 0: ui.preview_scroll -= 1
640
+ elif c == 'b' or c == 'q': ui.tools_mode = 'list'
641
+ return True
642
+
643
+ if c == 'j' and ui.tools_sel < len(MCP.tool_info) - 1: ui.tools_sel += 1
644
+ elif c == 'k' and ui.tools_sel > 0: ui.tools_sel -= 1
645
+ elif c == ' ' and MCP.tool_info:
646
+ MCP.tool_info[ui.tools_sel]['enabled'] = not MCP.tool_info[ui.tools_sel]['enabled']
647
+ _update_sys_msg()
648
+ elif c == 'a':
649
+ for t in MCP.tool_info: t['enabled'] = True
650
+ _update_sys_msg()
651
+ elif c == 'n':
652
+ for t in MCP.tool_info: t['enabled'] = False
653
+ _update_sys_msg()
654
+ elif c == 'p' and MCP.tool_info:
655
+ t = MCP.tool_info[ui.tools_sel]
656
+ lines = [f"Tool: {t['name']}", "=" * 40, "",
657
+ f"Server: {MCP.servers[t['server_idx']]['path'] if t['server_idx'] < len(MCP.servers) else '?'}",
658
+ f"Enabled: {t['enabled']}", "", "Description:", t['desc'], "", "Parameters:"]
659
+ props = t['params'].get('properties', {})
660
+ req = t['params'].get('required', [])
661
+ for pn, pi in props.items():
662
+ r = "*" if pn in req else ""
663
+ lines.append(f" {pn}{r} ({pi.get('type','any')}): {pi.get('description','')[:60]}")
664
+ if not props: lines.append(" (none)")
665
+ ui.preview_lines = lines
666
+ ui.preview_scroll = 0
667
+ ui.tools_mode = 'preview'
668
+ return True
669
+
670
+ def handle_servers(c, fd):
671
+ if ui.srv_adding:
672
+ if c in ('\r', '\n'):
673
+ path = ui.srv_buf.strip()
674
+ ui.srv_adding = False
675
+ ui.srv_buf = ""
676
+ if path:
677
+ lp = get_loop()
678
+ try:
679
+ lp.run_until_complete(connect_mcp(path))
680
+ _update_sys_msg()
681
+ except Exception as e:
682
+ ui.chat_log.append(('error', f'Connect failed: {e}'))
683
+ elif c == '\x7f' or c == '\x08':
684
+ ui.srv_buf = ui.srv_buf[:-1]
685
+ elif c >= ' ' and c <= '~':
686
+ ui.srv_buf += c
687
+ return True
688
+
689
+ if c == 'j' and ui.srv_sel < len(MCP.servers) - 1: ui.srv_sel += 1
690
+ elif c == 'k' and ui.srv_sel > 0: ui.srv_sel -= 1
691
+ elif c == 'a':
692
+ ui.srv_adding = True
693
+ ui.srv_buf = ""
694
+ elif c == 'd' and MCP.servers and ui.srv_sel < len(MCP.servers):
695
+ lp = get_loop()
696
+ lp.run_until_complete(disconnect_srv(ui.srv_sel))
697
+ _update_sys_msg()
698
+ elif c == 'r' and MCP.servers and ui.srv_sel < len(MCP.servers):
699
+ s = MCP.servers[ui.srv_sel]
700
+ if not s['connected']:
701
+ path = s['path']
702
+ MCP.tool_info = [t for t in MCP.tool_info if t['server_idx'] != ui.srv_sel]
703
+ MCP.servers.pop(ui.srv_sel)
704
+ for t in MCP.tool_info:
705
+ if t['server_idx'] > ui.srv_sel: t['server_idx'] -= 1
706
+ lp = get_loop()
331
707
  try:
332
- loop = asyncio.get_event_loop()
333
- except RuntimeError:
334
- loop = asyncio.new_event_loop()
335
- asyncio.set_event_loop(loop)
336
- tools_llm, tool_map = loop.run_until_complete(connect_mcp(path))
337
- if tools_llm:
338
- break
339
-
340
- # Ensure system message
341
- if not messages or messages[0].get("role") != "system":
342
- sys_msg = get_system_message(npc) if npc else "You are an AI assistant with access to tools."
343
- if tools_llm:
344
- sys_msg += f"\n\nYou have access to these tools: {', '.join(t['function']['name'] for t in tools_llm)}"
345
- messages.insert(0, {"role": "system", "content": sys_msg})
346
-
347
- # Handle initial command if provided (one-shot mode)
348
- if initial_command:
349
- resp = get_llm_response(
350
- initial_command,
351
- model=model,
352
- provider=provider,
353
- messages=messages,
354
- tools=tools_llm if tools_llm else None,
355
- tool_map=tool_map if tool_map else None,
356
- auto_process_tool_calls=True,
357
- npc=npc
358
- )
359
- messages = resp.get('messages', messages)
360
- render_markdown(str(resp.get('response', '')))
361
- context['output'] = resp.get('response', 'Done.')
362
- context['messages'] = messages
363
- # Don't enter REPL for one-shot
708
+ lp.run_until_complete(connect_mcp(path))
709
+ _update_sys_msg()
710
+ except Exception as e:
711
+ ui.chat_log.append(('error', f'Reconnect failed: {e}'))
712
+ ui.srv_sel = min(ui.srv_sel, max(0, len(MCP.servers) - 1))
713
+ return True
714
+
715
+ def _update_sys_msg():
716
+ active = MCP.active_tools()
717
+ base = get_system_message(_npc) if _npc else "You are an AI assistant with access to tools."
718
+ if active:
719
+ base += f"\n\nYou have access to these tools: {', '.join(t['function']['name'] for t in active)}"
720
+ for i, m in enumerate(_messages):
721
+ if m.get("role") == "system":
722
+ _messages[i] = {"role": "system", "content": base}
723
+ return
724
+ _messages.insert(0, {"role": "system", "content": base})
725
+
726
+ # ================================================================
727
+ # Non-interactive / one-shot
728
+ # ================================================================
729
+ if not sys.stdin.isatty():
730
+ if _init_cmd:
731
+ # System message
732
+ sys_msg = get_system_message(_npc) if _npc else "You are an AI assistant."
733
+ _messages.insert(0, {"role": "system", "content": sys_msg})
734
+ resp = llm_call(_init_cmd, _messages)
735
+ _messages[:] = resp.get('messages', _messages)
736
+ context['output'] = str(resp.get('response', ''))
737
+ else:
738
+ context['output'] = "Corca requires an interactive terminal."
739
+ context['messages'] = _messages
364
740
  exit()
365
741
 
366
- # REPL loop
367
- while True:
368
- try:
369
- prompt_str = f"{npc_name}:corca> "
370
- user_input = input(prompt_str).strip()
371
-
372
- if not user_input:
373
- continue
374
-
375
- if user_input.lower() == "/cq":
376
- print("Exiting corca mode.")
742
+ # ================================================================
743
+ # Auto-connect
744
+ # ================================================================
745
+ auto_paths = []
746
+ if _mcp_path:
747
+ auto_paths.append(_mcp_path)
748
+ # Check npcsh package directory (where mcp_server.py is shipped)
749
+ try:
750
+ import npcsh as _npcsh_mod
751
+ _pkg_mcp = os.path.join(os.path.dirname(_npcsh_mod.__file__), 'mcp_server.py')
752
+ if _pkg_mcp not in auto_paths:
753
+ auto_paths.append(_pkg_mcp)
754
+ except ImportError:
755
+ pass
756
+ # Team path
757
+ if _team and hasattr(_team, 'team_path'):
758
+ tp = os.path.join(_team.team_path, "mcp_server.py")
759
+ if tp not in auto_paths:
760
+ auto_paths.append(tp)
761
+ # Home npc_team
762
+ _home_mcp = os.path.expanduser("~/.npcsh/npc_team/mcp_server.py")
763
+ if _home_mcp not in auto_paths:
764
+ auto_paths.append(_home_mcp)
765
+
766
+ lp = get_loop()
767
+ _tried = []
768
+ for p in auto_paths:
769
+ ep = os.path.expanduser(p)
770
+ if os.path.exists(ep):
771
+ _tried.append(ep)
772
+ try:
773
+ lp.run_until_complete(connect_mcp(p))
774
+ except Exception as e:
775
+ ui.chat_log.append(('error', f'Auto-connect {os.path.basename(ep)}: {e}'))
776
+ if MCP.tool_info:
377
777
  break
378
-
379
- # Handle /tools to browse available tools with TUI
380
- if user_input.lower() == "/tools":
381
- result = tools_tui_browser(tools_llm)
382
- if result:
383
- print(colored(f"Selected tool: {result}", "cyan"))
384
- print(colored("Use it by describing what you want to do.", "gray"))
385
- continue
386
-
387
- # Handle /connect to connect to new MCP server
388
- if user_input.startswith("/connect "):
389
- new_path = user_input[9:].strip()
390
- try:
391
- loop = asyncio.get_event_loop()
392
- except RuntimeError:
393
- loop = asyncio.new_event_loop()
394
- asyncio.set_event_loop(loop)
395
- tools_llm, tool_map = loop.run_until_complete(connect_mcp(new_path))
396
- continue
397
-
398
- # Get LLM response with tools
399
- resp = get_llm_response(
400
- user_input,
401
- model=model,
402
- provider=provider,
403
- messages=messages,
404
- tools=tools_llm if tools_llm else None,
405
- tool_map=tool_map if tool_map else None,
406
- auto_process_tool_calls=True,
407
- stream=False, # Tool calls don't work well with streaming
408
- npc=npc
409
- )
410
-
411
- messages = resp.get('messages', messages)
412
- response_text = resp.get('response', '')
413
- render_markdown(str(response_text))
414
-
415
- # Track usage
416
- if 'usage' in resp and npc and hasattr(npc, 'shared_context'):
417
- usage = resp['usage']
418
- npc.shared_context['session_input_tokens'] += usage.get('input_tokens', 0)
419
- npc.shared_context['session_output_tokens'] += usage.get('output_tokens', 0)
420
- npc.shared_context['turn_count'] += 1
421
-
422
- except KeyboardInterrupt:
423
- print("\nUse '/cq' to exit or continue.")
424
- continue
425
- except EOFError:
426
- print("\nExiting corca mode.")
427
- break
428
-
429
- context['output'] = "Exited corca mode."
430
- context['messages'] = messages
778
+ if not MCP.tool_info and not _tried:
779
+ ui.chat_log.append(('info', f'No mcp_server.py found. Searched: {", ".join(auto_paths)}'))
780
+
781
+ _update_sys_msg()
782
+ ui.last_msg_idx = len(_messages)
783
+ ui.chat_log.append(('info', f'Welcome to CORCA. NPC: {_npc_name}. {len(MCP.tool_info)} tools available.'))
784
+
785
+ # One-shot
786
+ if _init_cmd:
787
+ send_message(_init_cmd)
788
+
789
+ # ================================================================
790
+ # Main loop
791
+ # ================================================================
792
+ fd = sys.stdin.fileno()
793
+ old_settings = termios.tcgetattr(fd)
794
+ try:
795
+ tty.setcbreak(fd)
796
+ sys.stdout.write('\033[?25l\033[2J')
797
+ running = True
798
+ while running:
799
+ render()
800
+ if ui.thinking:
801
+ ui.spinner_frame += 1
802
+ if _sel.select([fd], [], [], 0.15)[0]:
803
+ c = os.read(fd, 1).decode('latin-1')
804
+ running = handle_key(c, fd)
805
+ finally:
806
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
807
+ sys.stdout.write('\033[?25h\033[2J\033[H')
808
+ sys.stdout.flush()
809
+
810
+ # Cleanup MCP
811
+ lp = get_loop()
812
+ for s in MCP.servers:
813
+ if s['connected']:
814
+ try:
815
+ async def _cl(es): await es.aclose()
816
+ lp.run_until_complete(_cl(s['exit_stack']))
817
+ except: pass
818
+
819
+ context['output'] = "Exited corca."
820
+ context['messages'] = _messages