kash-shell 0.3.8__py3-none-any.whl → 0.3.10__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 (154) hide show
  1. kash/actions/__init__.py +4 -4
  2. kash/actions/core/markdownify.py +5 -2
  3. kash/actions/core/readability.py +5 -2
  4. kash/actions/core/render_as_html.py +18 -0
  5. kash/actions/core/webpage_config.py +12 -4
  6. kash/commands/__init__.py +8 -20
  7. kash/commands/base/basic_file_commands.py +15 -0
  8. kash/commands/base/debug_commands.py +15 -2
  9. kash/commands/base/general_commands.py +27 -18
  10. kash/commands/base/logs_commands.py +1 -4
  11. kash/commands/base/model_commands.py +8 -8
  12. kash/commands/base/search_command.py +3 -2
  13. kash/commands/base/show_command.py +5 -3
  14. kash/commands/extras/parse_uv_lock.py +186 -0
  15. kash/commands/help/doc_commands.py +2 -31
  16. kash/commands/help/welcome.py +33 -0
  17. kash/commands/workspace/selection_commands.py +11 -6
  18. kash/commands/workspace/workspace_commands.py +19 -16
  19. kash/config/colors.py +2 -0
  20. kash/config/env_settings.py +72 -0
  21. kash/config/init.py +2 -2
  22. kash/config/logger.py +61 -59
  23. kash/config/logger_basic.py +12 -5
  24. kash/config/server_config.py +6 -6
  25. kash/config/settings.py +117 -67
  26. kash/config/setup.py +35 -9
  27. kash/config/suppress_warnings.py +30 -12
  28. kash/config/text_styles.py +3 -13
  29. kash/docs/load_api_docs.py +2 -1
  30. kash/docs/markdown/topics/a2_installation.md +7 -3
  31. kash/docs/markdown/topics/a3_getting_started.md +3 -2
  32. kash/docs/markdown/warning.md +3 -8
  33. kash/docs/markdown/welcome.md +4 -0
  34. kash/docs_base/load_recipe_snippets.py +1 -1
  35. kash/docs_base/recipes/{general_system_commands.ksh → general_system_commands.sh} +1 -1
  36. kash/{concepts → embeddings}/cosine.py +2 -1
  37. kash/embeddings/text_similarity.py +57 -0
  38. kash/exec/__init__.py +20 -3
  39. kash/exec/action_decorators.py +18 -4
  40. kash/exec/action_exec.py +41 -23
  41. kash/exec/action_registry.py +13 -48
  42. kash/exec/command_registry.py +2 -1
  43. kash/exec/fetch_url_metadata.py +4 -6
  44. kash/exec/importing.py +56 -0
  45. kash/exec/llm_transforms.py +6 -6
  46. kash/exec/precondition_registry.py +2 -1
  47. kash/exec/preconditions.py +16 -1
  48. kash/exec/shell_callable_action.py +33 -19
  49. kash/file_storage/file_store.py +23 -14
  50. kash/file_storage/item_file_format.py +13 -3
  51. kash/file_storage/metadata_dirs.py +11 -2
  52. kash/help/assistant.py +2 -2
  53. kash/help/assistant_instructions.py +2 -1
  54. kash/help/help_embeddings.py +2 -2
  55. kash/help/help_printing.py +14 -10
  56. kash/help/tldr_help.py +5 -3
  57. kash/llm_utils/clean_headings.py +1 -1
  58. kash/llm_utils/llm_api_keys.py +4 -4
  59. kash/llm_utils/llm_completion.py +2 -2
  60. kash/llm_utils/llm_features.py +68 -0
  61. kash/llm_utils/llm_messages.py +1 -2
  62. kash/llm_utils/llm_names.py +1 -1
  63. kash/llm_utils/llms.py +17 -12
  64. kash/local_server/__init__.py +5 -2
  65. kash/local_server/local_server.py +56 -46
  66. kash/local_server/local_server_commands.py +15 -15
  67. kash/local_server/local_server_routes.py +2 -2
  68. kash/local_server/local_url_formatters.py +1 -1
  69. kash/mcp/__init__.py +5 -2
  70. kash/mcp/mcp_cli.py +54 -17
  71. kash/mcp/mcp_server_commands.py +5 -6
  72. kash/mcp/mcp_server_routes.py +14 -11
  73. kash/mcp/mcp_server_sse.py +61 -34
  74. kash/mcp/mcp_server_stdio.py +0 -8
  75. kash/media_base/audio_processing.py +81 -7
  76. kash/media_base/media_cache.py +18 -18
  77. kash/media_base/media_services.py +1 -1
  78. kash/media_base/media_tools.py +6 -6
  79. kash/media_base/services/local_file_media.py +2 -2
  80. kash/media_base/{speech_transcription.py → transcription_deepgram.py} +25 -109
  81. kash/media_base/transcription_format.py +73 -0
  82. kash/media_base/transcription_whisper.py +38 -0
  83. kash/model/__init__.py +73 -5
  84. kash/model/actions_model.py +38 -4
  85. kash/model/concept_model.py +30 -0
  86. kash/model/items_model.py +56 -13
  87. kash/model/params_model.py +24 -0
  88. kash/shell/completions/completion_scoring.py +37 -5
  89. kash/shell/output/kerm_codes.py +1 -2
  90. kash/shell/output/shell_formatting.py +14 -4
  91. kash/shell/shell_main.py +2 -2
  92. kash/shell/utils/exception_printing.py +6 -0
  93. kash/shell/utils/native_utils.py +26 -20
  94. kash/text_handling/custom_sliding_transforms.py +12 -4
  95. kash/text_handling/doc_normalization.py +6 -2
  96. kash/text_handling/markdown_render.py +117 -0
  97. kash/text_handling/markdown_utils.py +204 -0
  98. kash/utils/common/import_utils.py +12 -3
  99. kash/utils/common/type_utils.py +0 -29
  100. kash/utils/common/url.py +80 -28
  101. kash/utils/errors.py +6 -0
  102. kash/utils/file_utils/{dir_size.py → dir_info.py} +25 -4
  103. kash/utils/file_utils/file_ext.py +2 -3
  104. kash/utils/file_utils/file_formats.py +28 -2
  105. kash/utils/file_utils/file_formats_model.py +50 -19
  106. kash/utils/file_utils/filename_parsing.py +10 -4
  107. kash/web_content/dir_store.py +1 -2
  108. kash/web_content/file_cache_utils.py +37 -10
  109. kash/web_content/file_processing.py +68 -0
  110. kash/web_content/local_file_cache.py +12 -9
  111. kash/web_content/web_extract.py +8 -3
  112. kash/web_content/web_fetch.py +12 -4
  113. kash/web_gen/tabbed_webpage.py +5 -2
  114. kash/web_gen/templates/base_styles.css.jinja +120 -14
  115. kash/web_gen/templates/base_webpage.html.jinja +60 -13
  116. kash/web_gen/templates/content_styles.css.jinja +4 -2
  117. kash/web_gen/templates/item_view.html.jinja +2 -2
  118. kash/web_gen/templates/tabbed_webpage.html.jinja +1 -2
  119. kash/workspaces/__init__.py +15 -2
  120. kash/workspaces/selections.py +18 -3
  121. kash/workspaces/source_items.py +4 -2
  122. kash/workspaces/workspace_output.py +11 -4
  123. kash/workspaces/workspaces.py +5 -11
  124. kash/xonsh_custom/command_nl_utils.py +40 -19
  125. kash/xonsh_custom/custom_shell.py +44 -12
  126. kash/xonsh_custom/customize_prompt.py +39 -21
  127. kash/xonsh_custom/load_into_xonsh.py +26 -27
  128. kash/xonsh_custom/shell_load_commands.py +2 -2
  129. kash/xonsh_custom/xonsh_completers.py +2 -249
  130. kash/xonsh_custom/xonsh_keybindings.py +282 -0
  131. kash/xonsh_custom/xonsh_modern_tools.py +3 -3
  132. kash/xontrib/kash_extension.py +5 -6
  133. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/METADATA +26 -12
  134. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/RECORD +140 -140
  135. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/entry_points.txt +1 -1
  136. kash/concepts/concept_formats.py +0 -23
  137. kash/concepts/text_similarity.py +0 -112
  138. kash/shell/clideps/api_keys.py +0 -99
  139. kash/shell/clideps/dotenv_setup.py +0 -114
  140. kash/shell/clideps/dotenv_utils.py +0 -89
  141. kash/shell/clideps/pkg_deps.py +0 -232
  142. kash/shell/clideps/platforms.py +0 -11
  143. kash/shell/clideps/terminal_features.py +0 -56
  144. kash/shell/utils/osc_utils.py +0 -95
  145. kash/shell/utils/terminal_images.py +0 -133
  146. kash/text_handling/markdown_util.py +0 -167
  147. kash/utils/common/atomic_var.py +0 -158
  148. kash/utils/common/string_replace.py +0 -93
  149. kash/utils/common/string_template.py +0 -101
  150. /kash/docs_base/recipes/{python_dev_commands.ksh → python_dev_commands.sh} +0 -0
  151. /kash/docs_base/recipes/{tldr_standard_commands.ksh → tldr_standard_commands.sh} +0 -0
  152. /kash/{concepts → embeddings}/embeddings.py +0 -0
  153. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/WHEEL +0 -0
  154. {kash_shell-0.3.8.dist-info → kash_shell-0.3.10.dist-info}/licenses/LICENSE +0 -0
@@ -1,7 +1,6 @@
1
1
  from kash.config.logger import get_logger
2
- from kash.config.settings import global_settings, server_log_file_path
2
+ from kash.config.settings import global_settings
3
3
  from kash.exec import kash_command
4
- from kash.local_server.local_server import LOCAL_SERVER_NAME
5
4
  from kash.local_server.local_url_formatters import enable_local_urls
6
5
  from kash.shell.utils.native_utils import tail_file
7
6
  from kash.utils.errors import InvalidState
@@ -10,49 +9,50 @@ log = get_logger(__name__)
10
9
 
11
10
 
12
11
  @kash_command
13
- def start_local_server() -> None:
12
+ def start_ui_server() -> None:
14
13
  """
15
- Start the kash local server. This exposes local info on files and commands so they can be displayed in your terminal, if it supports OSC 8 links.
14
+ Start the kash local ui server. This exposes local info on files and commands so
15
+ they can be displayed in your terminal, if it supports OSC 8 links.
16
16
  Note this is most useful for the Kerm terminal, which shows links as
17
17
  tooltips.
18
18
  """
19
- from kash.local_server.local_server import start_local_server
19
+ from kash.local_server.local_server import start_ui_server
20
20
 
21
- start_local_server()
21
+ start_ui_server()
22
22
  enable_local_urls(True)
23
23
 
24
24
 
25
25
  @kash_command
26
- def stop_local_server() -> None:
26
+ def stop_ui_server() -> None:
27
27
  """
28
28
  Stop the kash local server.
29
29
  """
30
- from kash.local_server.local_server import stop_local_server
30
+ from kash.local_server.local_server import stop_ui_server
31
31
 
32
- stop_local_server()
32
+ stop_ui_server()
33
33
  enable_local_urls(False)
34
34
 
35
35
 
36
36
  @kash_command
37
- def restart_local_server() -> None:
37
+ def restart_ui_server() -> None:
38
38
  """
39
39
  Restart the kash local server.
40
40
  """
41
- from kash.local_server.local_server import restart_local_server
41
+ from kash.local_server.local_server import restart_ui_server
42
42
 
43
- restart_local_server()
43
+ restart_ui_server()
44
44
 
45
45
 
46
46
  @kash_command
47
47
  def local_server_logs(follow: bool = False) -> None:
48
48
  """
49
- Show the logs from the kash local server.
49
+ Show the logs from the kash local (UI and MCP) servers.
50
50
 
51
51
  :param follow: Follow the file as it grows.
52
52
  """
53
- log_path = server_log_file_path(LOCAL_SERVER_NAME, global_settings().local_server_port)
53
+ log_path = global_settings().local_server_log_path
54
54
  if not log_path.exists():
55
55
  raise InvalidState(
56
- f"Local server log not found (forgot to run `start_local_server`?): {log_path}"
56
+ f"Local ui server log not found (forgot to run `start_ui_server`?): {log_path}"
57
57
  )
58
58
  tail_file(log_path, follow=follow)
@@ -43,11 +43,11 @@ def format_local_url(route_path: str, **params: str | None) -> str:
43
43
  """
44
44
  URL to content on the local server.
45
45
  """
46
- from kash.local_server.local_server import LOCAL_SERVER_HOST
46
+ from kash.local_server.local_server import UI_SERVER_HOST
47
47
 
48
48
  settings = global_settings()
49
49
  route_path = route_path.strip("/")
50
- url = f"http://{LOCAL_SERVER_HOST}:{settings.local_server_port}/{route_path}"
50
+ url = f"http://{UI_SERVER_HOST}:{settings.local_server_port}/{route_path}"
51
51
  if params:
52
52
  query_params = {k: v for k, v in params.items() if v is not None}
53
53
  if query_params:
@@ -4,13 +4,13 @@ from pathlib import Path
4
4
 
5
5
  from rich.style import Style
6
6
  from rich.text import Text
7
+ from strif import AtomicVar
7
8
  from typing_extensions import override
8
9
 
9
10
  from kash.config.logger import get_logger
10
11
  from kash.config.text_styles import STYLE_HINT
11
12
  from kash.model.paths_model import StorePath
12
13
  from kash.shell.output.kerm_codes import KriLink, TextTooltip, UIAction, UIActionType
13
- from kash.utils.common.atomic_var import AtomicVar
14
14
  from kash.utils.common.format_utils import fmt_loc
15
15
  from kash.utils.errors import InvalidState
16
16
  from kash.workspaces import current_ws
kash/mcp/__init__.py CHANGED
@@ -1,2 +1,5 @@
1
- # Ensure commands are registered.
2
- import kash.mcp.mcp_server_commands # noqa: F401
1
+ from pathlib import Path
2
+
3
+ from kash.exec.importing import import_and_register
4
+
5
+ import_and_register(__package__, Path(__file__).parent, ["."])
kash/mcp/mcp_cli.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """
2
- Command-line launcher for running an MCP server. By default runs in stdio
3
- standalone mode, with all kash tools exposed. But can be run in SSE standalone
2
+ Command-line launcher for running an MCP server. With no options, by default runs in
3
+ stdio standalone mode, with all kash tools exposed. But can be run in SSE standalone
4
4
  mode or as a stdio proxy to another SSE server.
5
5
  """
6
6
 
@@ -9,27 +9,24 @@ import logging
9
9
  import os
10
10
  from pathlib import Path
11
11
 
12
- from kash.config.logger_basic import basic_logging_setup
13
- from kash.config.settings import DEFAULT_MCP_SERVER_PORT, LogLevel, get_system_logs_dir
14
- from kash.config.setup import setup
15
- from kash.mcp.mcp_main import McpMode, run_mcp_server
16
- from kash.mcp.mcp_server_sse import MCP_LOG_PREFIX
12
+ from kash.config.settings import DEFAULT_MCP_SERVER_PORT, LogLevel, global_settings
13
+ from kash.config.setup import kash_setup
17
14
  from kash.shell.utils.argparse_utils import WrappedColorFormatter
18
15
  from kash.shell.version import get_version
19
- from kash.workspaces.workspaces import Workspace, get_ws, global_ws_dir
20
16
 
21
17
  __version__ = get_version()
22
18
 
23
19
  DEFAULT_PROXY_URL = f"http://localhost:{DEFAULT_MCP_SERVER_PORT}/sse"
24
20
 
25
- LOG_PATH = get_system_logs_dir() / f"{MCP_LOG_PREFIX}_cli.log"
21
+ MCP_CLI_LOG_PATH = global_settings().system_logs_dir / "mcp_cli.log"
26
22
 
27
- basic_logging_setup(LOG_PATH, LogLevel.info)
28
23
 
29
24
  log = logging.getLogger()
30
25
 
31
26
 
32
27
  def build_parser():
28
+ from kash.workspaces.workspaces import global_ws_dir
29
+
33
30
  parser = argparse.ArgumentParser(description=__doc__, formatter_class=WrappedColorFormatter)
34
31
  parser.add_argument(
35
32
  "--version",
@@ -54,21 +51,50 @@ def build_parser():
54
51
  f"{DEFAULT_PROXY_URL}"
55
52
  ),
56
53
  )
57
- parser.add_argument("--sse", action="store_true", help="Run in SSE standalone mode")
54
+ parser.add_argument(
55
+ "--sse", action="store_true", help="Run in SSE standalone mode instead of stdio"
56
+ )
57
+ parser.add_argument(
58
+ "--list_tools",
59
+ action="store_true",
60
+ help="List tools that will be available",
61
+ )
62
+ parser.add_argument(
63
+ "--tool_help",
64
+ action="store_true",
65
+ help="Show full help for each tool",
66
+ )
67
+
58
68
  return parser
59
69
 
60
70
 
61
- def main():
62
- args = build_parser().parse_args()
71
+ def show_tool_info(full_help: bool):
72
+ from kash.exec.action_registry import get_all_actions_defaults
73
+ from kash.help.help_printing import print_action_help
74
+ from kash.mcp.mcp_server_routes import get_published_mcp_tools, publish_mcp_tools
75
+ from kash.shell.output.shell_output import cprint
63
76
 
64
- base_dir = Path(args.workspace)
77
+ publish_mcp_tools()
78
+ tools = get_published_mcp_tools()
79
+ cprint("Actions available as MCP tools:")
80
+ cprint()
81
+ actions = get_all_actions_defaults()
82
+ for tool in tools:
83
+ action = actions[tool]
84
+ print_action_help(
85
+ action, verbose=False, include_options=full_help, include_precondition=full_help
86
+ )
87
+ cprint()
65
88
 
66
- setup(rich_logging=False)
67
89
 
68
- log.warning("kash MCP CLI started, logging to: %s", LOG_PATH)
90
+ def run_server(args: argparse.Namespace):
91
+ from kash.mcp.mcp_main import McpMode, run_mcp_server
92
+ from kash.workspaces.workspaces import Workspace, get_ws
93
+
94
+ log.warning("kash MCP CLI started, logging to: %s", MCP_CLI_LOG_PATH)
69
95
  log.warning("Current working directory: %s", Path(".").resolve())
70
96
 
71
- ws: Workspace = get_ws(name_or_path=base_dir, auto_init=True)
97
+ ws: Workspace = get_ws(name_or_path=Path(args.workspace), auto_init=True)
72
98
  os.chdir(ws.base_dir)
73
99
  log.warning("Running in workspace: %s", ws.base_dir)
74
100
 
@@ -83,5 +109,16 @@ def main():
83
109
  run_mcp_server(mcp_mode, proxy_to=proxy_to)
84
110
 
85
111
 
112
+ def main():
113
+ args = build_parser().parse_args()
114
+
115
+ if args.list_tools or args.tool_help:
116
+ kash_setup(rich_logging=True, level=LogLevel.warning)
117
+ show_tool_info(args.tool_help)
118
+ else:
119
+ kash_setup(rich_logging=False, level=LogLevel.info)
120
+ run_server(args)
121
+
122
+
86
123
  if __name__ == "__main__":
87
124
  main()
@@ -3,13 +3,11 @@ from pathlib import Path
3
3
 
4
4
  from kash.config.logger import get_logger
5
5
  from kash.config.settings import (
6
- get_system_logs_dir,
7
6
  global_settings,
8
- server_log_file_path,
9
7
  )
10
8
  from kash.exec import kash_command
11
9
  from kash.mcp import mcp_server_routes
12
- from kash.mcp.mcp_server_sse import MCP_LOG_PREFIX, MCP_SERVER_NAME
10
+ from kash.mcp.mcp_cli import MCP_CLI_LOG_PATH
13
11
  from kash.shell.output.shell_formatting import format_name_and_value
14
12
  from kash.shell.output.shell_output import cprint, print_h2
15
13
  from kash.shell.utils.native_utils import tail_file
@@ -59,13 +57,14 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
59
57
  :param follow: Follow the file as it grows.
60
58
  :param all: Show all logs, not just the server logs, including Claude Desktop logs if found.
61
59
  """
60
+ settings = global_settings()
62
61
  if all:
63
- global_log_base = get_system_logs_dir()
62
+ global_log_base = settings.system_logs_dir
64
63
  claude_log_base = Path("~/Library/Logs/Claude").expanduser()
65
64
  log_paths = []
66
65
  did_log = False
67
66
  while len(log_paths) == 0:
68
- log_paths = list(global_log_base.glob(f"{MCP_LOG_PREFIX}*.log"))
67
+ log_paths = [settings.local_server_log_path, MCP_CLI_LOG_PATH]
69
68
  claude_logs = list(claude_log_base.glob("mcp*.log"))
70
69
  if claude_logs:
71
70
  log.message("Found Claude Desktop logs, will also tail them: %s", claude_logs)
@@ -82,7 +81,7 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
82
81
  did_log = True
83
82
  time.sleep(1)
84
83
  else:
85
- server_log_path = server_log_file_path(MCP_SERVER_NAME, global_settings().mcp_server_port)
84
+ server_log_path = settings.local_server_log_path # MCP logs shared with local server logs.
86
85
  if not server_log_path.exists():
87
86
  raise InvalidState(
88
87
  f"MCP server log not found (forgot to run `start_mcp_server`?): {server_log_path}"
@@ -8,16 +8,16 @@ from funlog import log_calls
8
8
  from mcp.server.lowlevel import Server
9
9
  from mcp.types import Prompt, Resource, TextContent, Tool
10
10
  from prettyfmt import fmt_path
11
+ from strif import AtomicVar
11
12
 
12
13
  from kash.config.capture_output import CapturedOutput, captured_output
13
14
  from kash.config.logger import get_logger
14
- from kash.config.settings import get_mcp_ws_dir
15
+ from kash.config.settings import global_settings
15
16
  from kash.exec.action_exec import prepare_action_input, run_action_with_caching
16
17
  from kash.exec.action_registry import get_all_actions_defaults, look_up_action_class
17
18
  from kash.model.actions_model import Action, ActionResult, ExecContext
18
19
  from kash.model.params_model import TypedParamValues
19
20
  from kash.model.paths_model import StorePath
20
- from kash.utils.common.atomic_var import AtomicVar
21
21
  from kash.workspaces.workspaces import current_ws, get_ws
22
22
 
23
23
  log = get_logger(__name__)
@@ -27,6 +27,13 @@ log = get_logger(__name__)
27
27
  _mcp_published_actions: AtomicVar[list[str]] = AtomicVar([])
28
28
 
29
29
 
30
+ def get_published_mcp_tools() -> list[str]:
31
+ """
32
+ Get the list of currently published MCP tools.
33
+ """
34
+ return _mcp_published_actions.copy()
35
+
36
+
30
37
  def publish_mcp_tools(action_names: list[str] | None = None) -> None:
31
38
  """
32
39
  Add actions to the list of published MCP tools.
@@ -40,12 +47,8 @@ def publish_mcp_tools(action_names: list[str] | None = None) -> None:
40
47
  with _mcp_published_actions.updates() as published_actions:
41
48
  new_actions = set(action_names).difference(published_actions)
42
49
  published_actions.extend(new_actions)
43
- log.message(
44
- "Published %s MCP tools (total now %s): %s",
45
- len(new_actions),
46
- len(published_actions),
47
- new_actions,
48
- )
50
+ log.message("Adding %s MCP tools (total now %s)", len(new_actions), len(published_actions))
51
+ log.info("Current MCP tools: %s", ", ".join(published_actions))
49
52
 
50
53
 
51
54
  def unpublish_mcp_tools(action_names: list[str] | None) -> None:
@@ -210,7 +213,7 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
210
213
  # XXX For now, unless the user has overridden the MCP workspace, we use the
211
214
  # current workspace, which could be changed by the user by changing working
212
215
  # directories. Maybe confusing?
213
- explicit_mcp_ws = get_mcp_ws_dir()
216
+ explicit_mcp_ws = global_settings().mcp_ws_dir
214
217
  ws = get_ws(explicit_mcp_ws) if explicit_mcp_ws else current_ws()
215
218
 
216
219
  with ws:
@@ -228,8 +231,8 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
228
231
  context = ExecContext(
229
232
  action=action,
230
233
  workspace_dir=ws.base_dir,
231
- # Enabling rerun always for now, seems good for tools.
232
- rerun=True,
234
+ rerun=True, # Enabling rerun always for now, seems good for tools.
235
+ refetch=False, # Using the file caches.
233
236
  # Keeping all transient files for now, but maybe make transient?
234
237
  override_state=None,
235
238
  )
@@ -3,9 +3,11 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import threading
5
5
  from functools import cached_property
6
+ from pathlib import Path
6
7
  from typing import TYPE_CHECKING
7
8
 
8
9
  from mcp.server.sse import SseServerTransport
10
+ from prettyfmt import fmt_path
9
11
  from sse_starlette.sse import AppStatus
10
12
  from starlette.applications import Starlette
11
13
  from starlette.routing import Mount, Route
@@ -16,14 +18,14 @@ if TYPE_CHECKING:
16
18
 
17
19
  from kash.config.logger import get_logger
18
20
  from kash.config.server_config import create_server_config
19
- from kash.config.settings import global_settings, server_log_file_path
21
+ from kash.config.settings import global_settings
22
+ from kash.local_server.port_tools import find_available_local_port
20
23
  from kash.mcp import mcp_server_routes
21
24
  from kash.utils.errors import InvalidState
22
25
 
23
26
  log = get_logger(__name__)
24
27
 
25
- MCP_LOG_PREFIX = "mcp"
26
- MCP_SERVER_NAME = f"{MCP_LOG_PREFIX}_server_sse"
28
+ MCP_SERVER_NAME = "mcp_server_sse"
27
29
  MCP_SERVER_HOST = "127.0.0.1"
28
30
  """The local hostname to run the MCP SSE server on."""
29
31
 
@@ -47,64 +49,84 @@ def create_mcp_app() -> Starlette:
47
49
 
48
50
 
49
51
  class MCPServerSSE:
50
- def __init__(self):
52
+ def __init__(self, server_name: str, host: str, log_path: Path):
53
+ self.server_name = server_name
54
+ self.host = host
55
+ self.log_path = log_path
51
56
  self.server_lock = threading.RLock()
52
57
  self.server_instance: uvicorn.Server | None = None
53
58
  self.did_exit = threading.Event()
59
+ self.port: int
54
60
 
55
61
  @cached_property
56
62
  def app(self) -> Starlette:
57
63
  return create_mcp_app()
58
64
 
59
- def _run_server(self):
65
+ @property
66
+ def host_port(self) -> str | None:
67
+ if self.server_instance:
68
+ return f"{self.server_instance.config.host}:{self.server_instance.config.port}"
69
+ else:
70
+ return None
71
+
72
+ def _setup_server(self):
60
73
  import uvicorn
61
74
 
62
75
  # Reset AppStatus.should_exit_event to None to ensure it's created
63
- # in the correct event loop when needed
76
+ # in the correct event loop when needed.
64
77
  AppStatus.should_exit_event = None
65
78
 
66
79
  port = global_settings().mcp_server_port
67
- self.log_path = server_log_file_path(MCP_SERVER_NAME, port)
68
- config = create_server_config(
69
- self.app, MCP_SERVER_HOST, port, MCP_SERVER_NAME, self.log_path
70
- )
71
- with self.server_lock:
72
- server = uvicorn.Server(config)
73
- self.server_instance = server
74
-
75
- async def serve():
76
- try:
77
- log.message(
78
- "Starting MCP server on %s:%s",
79
- MCP_SERVER_HOST,
80
- port,
81
- )
82
- await server.serve()
83
- finally:
84
- self.did_exit.set()
85
80
 
81
+ # Check if the port is available.
86
82
  try:
87
- asyncio.run(serve())
83
+ find_available_local_port(self.host, [port])
84
+ except RuntimeError:
85
+ log.warning(
86
+ f"MCP Server port {port} ({self.server_name}) is in use. Will not start another server."
87
+ )
88
+ return False
89
+
90
+ self.port = port
91
+
92
+ config = create_server_config(self.app, self.host, port, self.server_name, self.log_path)
93
+
94
+ server = uvicorn.Server(config)
95
+ self.server_instance = server
96
+ return True
97
+
98
+ def _run_server_thread(self):
99
+ assert self.server_instance
100
+ try:
101
+ asyncio.run(self.server_instance.serve())
88
102
  except Exception as e:
89
103
  log.error("MCP Server failed with error: %s", e)
90
104
  finally:
91
- with self.server_lock:
92
- self.server_instance = None
105
+ self.server_instance = None
106
+ self.did_exit.set()
93
107
 
94
108
  def start_server(self):
95
109
  with self.server_lock:
96
110
  if self.server_instance:
97
111
  log.warning(
98
- "MCP Server already running on %s:%s.",
99
- self.server_instance.config.host,
100
- self.server_instance.config.port,
112
+ "MCP Server already running on: %s",
113
+ self.host_port,
101
114
  )
102
115
  return
103
116
 
104
117
  self.did_exit.clear()
105
- server_thread = threading.Thread(target=self._run_server, daemon=True)
118
+ if not self._setup_server():
119
+ return
120
+
121
+ server_thread = threading.Thread(target=self._run_server_thread, daemon=True)
106
122
  server_thread.start()
107
123
  log.info("Created new MCP server thread: %s", server_thread)
124
+ log.message(
125
+ "Started server %s on %s with logs to %s",
126
+ self.server_name,
127
+ self.host_port,
128
+ fmt_path(self.log_path),
129
+ )
108
130
 
109
131
  def stop_server(self):
110
132
  with self.server_lock:
@@ -113,6 +135,7 @@ class MCPServerSSE:
113
135
  return
114
136
  self.server_instance.should_exit = True
115
137
 
138
+ # Wait a few seconds for the server to shut down.
116
139
  timeout = 5.0
117
140
  if not self.did_exit.wait(timeout=timeout):
118
141
  log.warning(
@@ -123,7 +146,7 @@ class MCPServerSSE:
123
146
  raise InvalidState(f"MCP Server did not shut down within {timeout} seconds")
124
147
 
125
148
  self.server_instance = None
126
- log.warning("MCP Server stopped.")
149
+ log.warning("Stopped server %s", self.server_name)
127
150
 
128
151
  def restart_server(self):
129
152
  self.stop_server()
@@ -131,7 +154,9 @@ class MCPServerSSE:
131
154
 
132
155
 
133
156
  # Singleton instance
134
- _mcp_sse_server = MCPServerSSE()
157
+ _mcp_sse_server = MCPServerSSE(
158
+ MCP_SERVER_NAME, MCP_SERVER_HOST, global_settings().local_server_log_path
159
+ )
135
160
 
136
161
 
137
162
  def start_mcp_server_sse():
@@ -150,7 +175,9 @@ def restart_mcp_server_sse():
150
175
 
151
176
 
152
177
  def run_mcp_server_sse():
153
- """Run server, blocking until shutdown. Handles graceful shutdown for both daemon and blocking usage."""
178
+ """
179
+ Run server, blocking until shutdown.
180
+ """
154
181
  try:
155
182
  start_mcp_server_sse()
156
183
  _mcp_sse_server.did_exit.wait()
@@ -5,18 +5,10 @@ import sys
5
5
  from anyio import ClosedResourceError
6
6
  from mcp.server.stdio import stdio_server
7
7
 
8
- from kash.config.settings import server_log_file_path
9
8
  from kash.mcp import mcp_server_routes
10
- from kash.mcp.mcp_server_sse import MCP_LOG_PREFIX
11
9
 
12
10
  log = logging.getLogger(__name__)
13
11
 
14
- MCP_SERVER_NAME = f"{MCP_LOG_PREFIX}_server_stdio"
15
-
16
-
17
- def get_log_path():
18
- return server_log_file_path(MCP_SERVER_NAME, "stdio")
19
-
20
12
 
21
13
  def run_mcp_server_stdio():
22
14
  """
@@ -1,13 +1,26 @@
1
1
  import logging
2
+ from dataclasses import dataclass
2
3
  from os.path import getsize
3
4
  from pathlib import Path
4
5
 
6
+ from prettyfmt import fmt_path, fmt_size_human
5
7
  from strif import atomic_output_file
6
8
 
7
9
  log = logging.getLogger(__name__)
8
10
 
9
11
 
10
- def downsample_to_16khz(audio_file_path: Path, downsampled_out_path: Path) -> None:
12
+ @dataclass(frozen=True)
13
+ class AudioFileStats:
14
+ duration: float
15
+ size: int
16
+
17
+ def __str__(self) -> str:
18
+ return f"duration {self.duration:.2f}s, size {fmt_size_human(self.size)}"
19
+
20
+
21
+ def downsample_to_16khz(
22
+ audio_file_path: Path, downsampled_out_path: Path
23
+ ) -> tuple[AudioFileStats, AudioFileStats]:
11
24
  from pydub import AudioSegment
12
25
 
13
26
  audio = AudioSegment.from_mp3(audio_file_path)
@@ -16,11 +29,72 @@ def downsample_to_16khz(audio_file_path: Path, downsampled_out_path: Path) -> No
16
29
  with atomic_output_file(downsampled_out_path) as temp_target:
17
30
  audio.export(temp_target, format="mp3")
18
31
 
32
+ before = AudioFileStats(
33
+ duration=len(audio) / 1000,
34
+ size=getsize(audio_file_path),
35
+ )
36
+ after = AudioFileStats(
37
+ duration=len(audio) / 1000,
38
+ size=getsize(downsampled_out_path),
39
+ )
19
40
  log.info(
20
- "Downsampled %s -> %s: size %s to 16kHz size %s (%sX reduction)",
21
- audio_file_path,
22
- downsampled_out_path,
23
- getsize(audio_file_path),
24
- getsize(downsampled_out_path),
25
- getsize(audio_file_path) / getsize(downsampled_out_path),
41
+ "Downsampled %s -> %s: %s to 16kHz %s (%sX reduction)",
42
+ fmt_path(audio_file_path),
43
+ fmt_path(downsampled_out_path),
44
+ before,
45
+ after,
46
+ before.size / after.size,
26
47
  )
48
+
49
+ return before, after
50
+
51
+
52
+ # TODO: Test and integrate with JSON caching of transcription results.
53
+ def slice_audio_segments(
54
+ audio_file_path: Path, segments: list[tuple[float, float]], output_path: Path
55
+ ) -> tuple[AudioFileStats, AudioFileStats]:
56
+ """
57
+ Takes a list of time segments in seconds and creates a new audio file
58
+ containing only those segments concatenated together.
59
+ """
60
+ from pydub import AudioSegment
61
+
62
+ # Load the audio file.
63
+ audio = AudioSegment.from_file(audio_file_path)
64
+ audio_duration = len(audio) / 1000
65
+
66
+ # Extract and concatenate each segment.
67
+ result: AudioSegment = AudioSegment.empty()
68
+ slices_duration = 0
69
+ for start_sec, end_sec in segments:
70
+ # Convert seconds to milliseconds for pydub.
71
+ start_ms = int(start_sec * 1000)
72
+ end_ms = int(end_sec * 1000)
73
+
74
+ # Extract the segment and add to result.
75
+ segment_audio = audio[start_ms:end_ms]
76
+ result += segment_audio
77
+ slices_duration += end_sec - start_sec
78
+
79
+ # Export the concatenated audio.
80
+ with atomic_output_file(output_path) as temp_target:
81
+ result.export(temp_target, format="mp3")
82
+
83
+ before = AudioFileStats(
84
+ duration=audio_duration,
85
+ size=getsize(audio_file_path),
86
+ )
87
+ after = AudioFileStats(
88
+ duration=slices_duration,
89
+ size=getsize(output_path),
90
+ )
91
+ log.info(
92
+ "Sliced audio: %s -> %s: extracted %d segments, %s to %s",
93
+ fmt_path(audio_file_path),
94
+ fmt_path(output_path),
95
+ len(segments),
96
+ before,
97
+ after,
98
+ )
99
+
100
+ return before, after