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.
- npcsh/_state.py +15 -76
- npcsh/benchmark/npcsh_agent.py +22 -14
- npcsh/benchmark/templates/install-npcsh.sh.j2 +2 -2
- npcsh/diff_viewer.py +3 -3
- npcsh/mcp_server.py +9 -1
- npcsh/npc_team/alicanto.npc +12 -6
- npcsh/npc_team/corca.npc +0 -1
- npcsh/npc_team/frederic.npc +2 -3
- npcsh/npc_team/jinxs/lib/core/compress.jinx +373 -85
- npcsh/npc_team/jinxs/lib/core/edit_file.jinx +83 -61
- npcsh/npc_team/jinxs/lib/core/search/db_search.jinx +17 -6
- npcsh/npc_team/jinxs/lib/core/search/file_search.jinx +17 -6
- npcsh/npc_team/jinxs/lib/core/search/web_search.jinx +52 -14
- npcsh/npc_team/jinxs/{bin → lib/utils}/benchmark.jinx +2 -2
- npcsh/npc_team/jinxs/{bin → lib/utils}/jinxs.jinx +12 -12
- npcsh/npc_team/jinxs/{bin → lib/utils}/models.jinx +7 -7
- npcsh/npc_team/jinxs/{bin → lib/utils}/setup.jinx +6 -6
- npcsh/npc_team/jinxs/modes/alicanto.jinx +1633 -295
- npcsh/npc_team/jinxs/modes/arxiv.jinx +5 -5
- npcsh/npc_team/jinxs/modes/build.jinx +378 -0
- npcsh/npc_team/jinxs/modes/config_tui.jinx +300 -0
- npcsh/npc_team/jinxs/modes/convene.jinx +597 -0
- npcsh/npc_team/jinxs/modes/corca.jinx +777 -387
- npcsh/npc_team/jinxs/modes/git.jinx +795 -0
- {npcsh-1.1.20.data/data/npcsh/npc_team → npcsh/npc_team/jinxs/modes}/kg.jinx +82 -15
- npcsh/npc_team/jinxs/modes/memories.jinx +414 -0
- npcsh/npc_team/jinxs/{bin → modes}/nql.jinx +10 -21
- npcsh/npc_team/jinxs/modes/papers.jinx +578 -0
- npcsh/npc_team/jinxs/modes/plonk.jinx +503 -308
- npcsh/npc_team/jinxs/modes/reattach.jinx +3 -3
- npcsh/npc_team/jinxs/modes/spool.jinx +3 -3
- npcsh/npc_team/jinxs/{bin → modes}/team.jinx +12 -12
- npcsh/npc_team/jinxs/modes/vixynt.jinx +388 -0
- npcsh/npc_team/jinxs/modes/wander.jinx +454 -181
- npcsh/npc_team/jinxs/modes/yap.jinx +630 -182
- npcsh/npc_team/kadiefa.npc +2 -1
- npcsh/npc_team/sibiji.npc +3 -3
- npcsh/npcsh.py +112 -47
- npcsh/routes.py +4 -1
- npcsh/salmon_simulation.py +0 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/alicanto.jinx +1694 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.npc +12 -6
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/arxiv.jinx +5 -5
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/benchmark.jinx +2 -2
- npcsh-1.1.22.data/data/npcsh/npc_team/build.jinx +378 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/compress.jinx +428 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/config_tui.jinx +300 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/corca.jinx +820 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.npc +0 -1
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/db_search.jinx +17 -6
- npcsh-1.1.22.data/data/npcsh/npc_team/edit_file.jinx +119 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/file_search.jinx +17 -6
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic.npc +2 -3
- npcsh-1.1.22.data/data/npcsh/npc_team/git.jinx +795 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/jinxs.jinx +12 -12
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.npc +2 -1
- {npcsh/npc_team/jinxs/bin → npcsh-1.1.22.data/data/npcsh/npc_team}/kg.jinx +82 -15
- npcsh-1.1.22.data/data/npcsh/npc_team/memories.jinx +414 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/models.jinx +7 -7
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/nql.jinx +10 -21
- npcsh-1.1.22.data/data/npcsh/npc_team/papers.jinx +578 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/plonk.jinx +574 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/reattach.jinx +3 -3
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/setup.jinx +6 -6
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.npc +3 -3
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.jinx +3 -3
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/team.jinx +12 -12
- npcsh-1.1.22.data/data/npcsh/npc_team/vixynt.jinx +388 -0
- npcsh-1.1.22.data/data/npcsh/npc_team/wander.jinx +728 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/web_search.jinx +52 -14
- npcsh-1.1.22.data/data/npcsh/npc_team/yap.jinx +716 -0
- {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/METADATA +246 -281
- npcsh-1.1.22.dist-info/RECORD +240 -0
- npcsh-1.1.22.dist-info/entry_points.txt +11 -0
- npcsh/npc_team/jinxs/bin/config_tui.jinx +0 -300
- npcsh/npc_team/jinxs/bin/memories.jinx +0 -317
- npcsh/npc_team/jinxs/bin/vixynt.jinx +0 -122
- npcsh/npc_team/jinxs/lib/core/search/kg_search.jinx +0 -418
- npcsh/npc_team/jinxs/lib/core/search/mem_review.jinx +0 -73
- npcsh/npc_team/jinxs/lib/core/search/mem_search.jinx +0 -388
- npcsh/npc_team/jinxs/lib/core/search.jinx +0 -54
- npcsh/npc_team/jinxs/lib/research/paper_search.jinx +0 -412
- npcsh/npc_team/jinxs/lib/research/semantic_scholar.jinx +0 -386
- npcsh/npc_team/jinxs/lib/utils/build.jinx +0 -65
- npcsh/npc_team/plonkjr.npc +0 -23
- npcsh-1.1.20.data/data/npcsh/npc_team/alicanto.jinx +0 -356
- npcsh-1.1.20.data/data/npcsh/npc_team/build.jinx +0 -65
- npcsh-1.1.20.data/data/npcsh/npc_team/compress.jinx +0 -140
- npcsh-1.1.20.data/data/npcsh/npc_team/config_tui.jinx +0 -300
- npcsh-1.1.20.data/data/npcsh/npc_team/corca.jinx +0 -430
- npcsh-1.1.20.data/data/npcsh/npc_team/edit_file.jinx +0 -97
- npcsh-1.1.20.data/data/npcsh/npc_team/kg_search.jinx +0 -418
- npcsh-1.1.20.data/data/npcsh/npc_team/mem_review.jinx +0 -73
- npcsh-1.1.20.data/data/npcsh/npc_team/mem_search.jinx +0 -388
- npcsh-1.1.20.data/data/npcsh/npc_team/memories.jinx +0 -317
- npcsh-1.1.20.data/data/npcsh/npc_team/paper_search.jinx +0 -412
- npcsh-1.1.20.data/data/npcsh/npc_team/plonk.jinx +0 -379
- npcsh-1.1.20.data/data/npcsh/npc_team/plonkjr.npc +0 -23
- npcsh-1.1.20.data/data/npcsh/npc_team/search.jinx +0 -54
- npcsh-1.1.20.data/data/npcsh/npc_team/semantic_scholar.jinx +0 -386
- npcsh-1.1.20.data/data/npcsh/npc_team/vixynt.jinx +0 -122
- npcsh-1.1.20.data/data/npcsh/npc_team/wander.jinx +0 -455
- npcsh-1.1.20.data/data/npcsh/npc_team/yap.jinx +0 -268
- npcsh-1.1.20.dist-info/RECORD +0 -248
- npcsh-1.1.20.dist-info/entry_points.txt +0 -25
- /npcsh/npc_team/jinxs/lib/{orchestration → core}/convene.jinx +0 -0
- /npcsh/npc_team/jinxs/lib/{orchestration → core}/delegate.jinx +0 -0
- /npcsh/npc_team/jinxs/{bin → lib/core}/sample.jinx +0 -0
- /npcsh/npc_team/jinxs/lib/{core → utils}/chat.jinx +0 -0
- /npcsh/npc_team/jinxs/lib/{core → utils}/cmd.jinx +0 -0
- /npcsh/npc_team/jinxs/{bin → lib/utils}/sync.jinx +0 -0
- /npcsh/npc_team/jinxs/{bin → modes}/roll.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/add_tab.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/alicanto.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_action.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/browser_screenshot.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/chat.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/click.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_browser.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_pane.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/close_tab.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/cmd.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/compile.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/confirm.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/convene.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/corca_example.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/delegate.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/focus_pane.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/frederic4.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.npc +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/guac.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/help.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/incognide.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/init.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/kadiefa.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/key_press.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/launch_app.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/list_panes.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/load_file.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/navigate.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/notify.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh.ctx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/npcsh_sibiji.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_browser.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/open_pane.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/ots.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/paste.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.npc +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonk.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/plonkjr.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/pti.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/python.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/read_pane.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/roll.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/run_terminal.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sample.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/screenshot.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/send_message.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/serve.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/set.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sh.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/shh.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sibiji.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sleep.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/split_pane.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/spool.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sql.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_npc.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switch_tab.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/switches.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/sync.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/teamviz.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/trigger.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/type_text.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/usage.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/verbose.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/wait.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/write_file.jinx +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/yap.png +0 -0
- {npcsh-1.1.20.data → npcsh-1.1.22.data}/data/npcsh/npc_team/zen_mode.jinx +0 -0
- {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/WHEEL +0 -0
- {npcsh-1.1.20.dist-info → npcsh-1.1.22.dist-info}/licenses/LICENSE +0 -0
- {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
|