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
@@ -0,0 +1,820 @@
1
+ jinx_name: corca
2
+ description: MCP-powered agentic shell with tabbed TUI
3
+ interactive: true
4
+ inputs:
5
+ - mcp_server_path: null
6
+ - initial_command: null
7
+ - model: null
8
+ - provider: null
9
+
10
+ steps:
11
+ - name: corca_tui
12
+ engine: python
13
+ code: |
14
+ import os, sys, tty, termios, asyncio, json, traceback, threading, time
15
+ import select as _sel
16
+ from contextlib import AsyncExitStack
17
+ from pathlib import Path
18
+ from termcolor import colored
19
+
20
+ from npcpy.llm_funcs import get_llm_response
21
+ from npcpy.npc_sysenv import render_markdown, get_system_message
22
+
23
+ try:
24
+ from litellm.exceptions import Timeout, ContextWindowExceededError, RateLimitError, BadRequestError
25
+ except ImportError:
26
+ Timeout = ContextWindowExceededError = RateLimitError = BadRequestError = Exception
27
+
28
+ try:
29
+ from mcp import ClientSession, StdioServerParameters
30
+ from mcp.client.stdio import stdio_client
31
+ MCP_AVAILABLE = True
32
+ except ImportError:
33
+ MCP_AVAILABLE = False
34
+
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()
84
+
85
+ class MCP:
86
+ servers = []
87
+ tool_info = []
88
+
89
+ @staticmethod
90
+ def active_tools():
91
+ return [t['tool_def'] for t in MCP.tool_info if t['enabled']]
92
+
93
+ @staticmethod
94
+ def active_map():
95
+ return {t['name']: t['call'] for t in MCP.tool_info if t['enabled']}
96
+
97
+ # ================================================================
98
+ # Helpers
99
+ # ================================================================
100
+ def sz():
101
+ try:
102
+ s = os.get_terminal_size()
103
+ return s.columns, s.lines
104
+ except:
105
+ return 80, 24
106
+
107
+ def get_loop():
108
+ try:
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)
150
+ else:
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
+ # ================================================================
197
+ async def connect_mcp(server_path):
198
+ if not MCP_AVAILABLE:
199
+ ui.chat_log.append(('error', 'MCP not available. pip install mcp-client'))
200
+ return False
201
+ abs_path = os.path.abspath(os.path.expanduser(server_path))
202
+ if not os.path.exists(abs_path):
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)
213
+ try:
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)
425
+ else:
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()
707
+ try:
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
740
+ exit()
741
+
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:
777
+ break
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