kash-shell 0.3.28__py3-none-any.whl → 0.3.33__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.
- kash/actions/core/chat.py +1 -0
- kash/actions/core/markdownify_html.py +4 -5
- kash/actions/core/minify_html.py +4 -5
- kash/actions/core/readability.py +1 -4
- kash/actions/core/render_as_html.py +10 -7
- kash/actions/core/save_sidematter_meta.py +47 -0
- kash/actions/core/show_webpage.py +2 -0
- kash/actions/core/zip_sidematter.py +47 -0
- kash/commands/base/basic_file_commands.py +7 -4
- kash/commands/base/diff_commands.py +6 -4
- kash/commands/base/files_command.py +31 -30
- kash/commands/base/general_commands.py +3 -2
- kash/commands/base/logs_commands.py +6 -4
- kash/commands/base/reformat_command.py +3 -2
- kash/commands/base/search_command.py +4 -3
- kash/commands/base/show_command.py +9 -7
- kash/commands/help/assistant_commands.py +6 -4
- kash/commands/help/help_commands.py +7 -4
- kash/commands/workspace/selection_commands.py +18 -16
- kash/commands/workspace/workspace_commands.py +39 -26
- kash/config/logger.py +1 -1
- kash/config/setup.py +2 -27
- kash/config/text_styles.py +1 -1
- kash/docs/markdown/topics/a1_what_is_kash.md +26 -18
- kash/docs/markdown/topics/a2_installation.md +3 -2
- kash/exec/action_decorators.py +7 -5
- kash/exec/action_exec.py +104 -53
- kash/exec/fetch_url_items.py +40 -11
- kash/exec/llm_transforms.py +14 -5
- kash/exec/preconditions.py +2 -2
- kash/exec/resolve_args.py +4 -1
- kash/exec/runtime_settings.py +3 -0
- kash/file_storage/file_store.py +108 -114
- kash/file_storage/item_file_format.py +91 -26
- kash/file_storage/item_id_index.py +128 -0
- kash/help/help_types.py +1 -1
- kash/llm_utils/llms.py +6 -1
- kash/local_server/local_server_commands.py +2 -1
- kash/mcp/mcp_server_commands.py +3 -2
- kash/mcp/mcp_server_routes.py +42 -12
- kash/model/actions_model.py +44 -32
- kash/model/compound_actions_model.py +4 -3
- kash/model/exec_model.py +33 -3
- kash/model/items_model.py +150 -60
- kash/model/params_model.py +4 -4
- kash/shell/output/shell_output.py +1 -2
- kash/utils/api_utils/gather_limited.py +2 -0
- kash/utils/api_utils/multitask_gather.py +74 -0
- kash/utils/common/s3_utils.py +108 -0
- kash/utils/common/url.py +16 -4
- kash/utils/file_formats/chat_format.py +7 -4
- kash/utils/file_utils/file_ext.py +1 -0
- kash/utils/file_utils/file_formats.py +4 -2
- kash/utils/file_utils/file_formats_model.py +12 -0
- kash/utils/text_handling/doc_normalization.py +1 -1
- kash/utils/text_handling/markdown_footnotes.py +224 -0
- kash/utils/text_handling/markdown_utils.py +532 -41
- kash/utils/text_handling/markdownify_utils.py +2 -1
- kash/web_content/web_fetch.py +2 -1
- kash/web_gen/templates/components/tooltip_scripts.js.jinja +186 -1
- kash/web_gen/templates/components/youtube_popover_scripts.js.jinja +223 -0
- kash/web_gen/templates/components/youtube_popover_styles.css.jinja +150 -0
- kash/web_gen/templates/content_styles.css.jinja +53 -1
- kash/web_gen/templates/youtube_webpage.html.jinja +47 -0
- kash/web_gen/webpage_render.py +103 -0
- kash/workspaces/workspaces.py +0 -5
- kash/xonsh_custom/custom_shell.py +4 -3
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/METADATA +35 -26
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/RECORD +72 -64
- kash/llm_utils/llm_features.py +0 -72
- kash/web_gen/simple_webpage.py +0 -55
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/WHEEL +0 -0
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/entry_points.txt +0 -0
- {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
from pathlib import Path
|
|
2
2
|
|
|
3
|
-
from frontmatter_format import
|
|
3
|
+
from frontmatter_format import (
|
|
4
|
+
FmStyle,
|
|
5
|
+
fmf_has_frontmatter,
|
|
6
|
+
fmf_read,
|
|
7
|
+
fmf_read_frontmatter,
|
|
8
|
+
fmf_write,
|
|
9
|
+
)
|
|
4
10
|
from funlog import tally_calls
|
|
5
11
|
from prettyfmt import custom_key_sort, fmt_size_human
|
|
12
|
+
from sidematter_format import Sidematter
|
|
13
|
+
from strif import atomic_output_file, single_line
|
|
6
14
|
|
|
7
15
|
from kash.config.logger import get_logger
|
|
8
16
|
from kash.model.items_model import ITEM_FIELDS, Item
|
|
@@ -22,19 +30,28 @@ _item_cache = MtimeCache[Item](max_size=2000, name="Item")
|
|
|
22
30
|
|
|
23
31
|
|
|
24
32
|
@tally_calls()
|
|
25
|
-
def write_item(item: Item, path: Path, normalize: bool = True):
|
|
33
|
+
def write_item(item: Item, path: Path, *, normalize: bool = True, use_frontmatter: bool = True):
|
|
26
34
|
"""
|
|
27
|
-
Write a text item to a file with standard frontmatter format YAML.
|
|
35
|
+
Write a text item to a file with standard frontmatter format YAML or sidematter format.
|
|
28
36
|
By default normalizes formatting of the body text and updates the item's body.
|
|
37
|
+
|
|
38
|
+
If `use_frontmatter` is True, uses frontmatter on the file for metadata, and omits
|
|
39
|
+
the sidematter metadata file.
|
|
40
|
+
|
|
41
|
+
This function does not explicitly write sidematter assets; these can be written
|
|
42
|
+
separately.
|
|
29
43
|
"""
|
|
30
44
|
item.validate()
|
|
31
|
-
if item.format and not item.format.supports_frontmatter:
|
|
45
|
+
if use_frontmatter and item.format and not item.format.supports_frontmatter:
|
|
32
46
|
raise ValueError(f"Item format `{item.format.value}` does not support frontmatter: {item}")
|
|
33
47
|
|
|
34
48
|
# Clear cache before writing.
|
|
35
49
|
_item_cache.delete(path)
|
|
36
50
|
|
|
51
|
+
title = item.title
|
|
37
52
|
if normalize:
|
|
53
|
+
if item.title:
|
|
54
|
+
title = single_line(item.title)
|
|
38
55
|
body = normalize_formatting(item.body_text(), item.format)
|
|
39
56
|
else:
|
|
40
57
|
body = item.body_text()
|
|
@@ -65,29 +82,44 @@ def write_item(item: Item, path: Path, normalize: bool = True):
|
|
|
65
82
|
|
|
66
83
|
log.debug("Writing item to %s: body length %s, metadata %s", path, len(body), item.metadata())
|
|
67
84
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
# Use sidematter format
|
|
86
|
+
spath = Sidematter(path)
|
|
87
|
+
if use_frontmatter:
|
|
88
|
+
# Use frontmatter format
|
|
89
|
+
fmf_write(
|
|
90
|
+
path,
|
|
91
|
+
body,
|
|
92
|
+
item.metadata(),
|
|
93
|
+
style=fm_style,
|
|
94
|
+
key_sort=ITEM_FIELD_SORT,
|
|
95
|
+
make_parents=True,
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
# Write the main file with just the body (no frontmatter)
|
|
99
|
+
with atomic_output_file(path, make_parents=True) as f:
|
|
100
|
+
f.write_text(body, encoding="utf-8")
|
|
101
|
+
|
|
102
|
+
# Sidematter metadata
|
|
103
|
+
spath.write_meta(item.metadata(), key_sort=ITEM_FIELD_SORT)
|
|
76
104
|
|
|
77
105
|
# Update cache.
|
|
78
106
|
_item_cache.update(path, item)
|
|
79
107
|
|
|
80
|
-
# Update the item
|
|
108
|
+
# Update the item.
|
|
109
|
+
item.title = title
|
|
81
110
|
item.body = body
|
|
82
111
|
|
|
83
112
|
|
|
84
|
-
def read_item(path: Path, base_dir: Path | None) -> Item:
|
|
113
|
+
def read_item(path: Path, base_dir: Path | None, preserve_filename: bool = True) -> Item:
|
|
85
114
|
"""
|
|
86
|
-
Read
|
|
115
|
+
Read a text item from a file. Uses `base_dir` to resolve paths, so the item's
|
|
87
116
|
`store_path` will be set and be relative to `base_dir`.
|
|
88
117
|
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
Automatically detects and reads sidematter format (metadata in .meta.yml/.meta.json
|
|
119
|
+
sidecar files), which takes precedence over frontmatter when present. Falls back to
|
|
120
|
+
frontmatter format YAML if no sidematter is found. If neither is present, the item
|
|
121
|
+
will be a resource with a format inferred from the file extension or the content.
|
|
122
|
+
|
|
91
123
|
The `store_path` will be the path relative to the `base_dir`, if the file
|
|
92
124
|
is within `base_dir`, or otherwise the `external_path` will be set to the path
|
|
93
125
|
it was read from.
|
|
@@ -98,20 +130,48 @@ def read_item(path: Path, base_dir: Path | None) -> Item:
|
|
|
98
130
|
log.debug("Cache hit for item: %s", path)
|
|
99
131
|
return cached_item
|
|
100
132
|
|
|
101
|
-
return _read_item_uncached(path, base_dir)
|
|
133
|
+
return _read_item_uncached(path, base_dir, preserve_filename=preserve_filename)
|
|
102
134
|
|
|
103
135
|
|
|
104
136
|
@tally_calls()
|
|
105
|
-
def _read_item_uncached(
|
|
137
|
+
def _read_item_uncached(
|
|
138
|
+
path: Path,
|
|
139
|
+
base_dir: Path | None,
|
|
140
|
+
*,
|
|
141
|
+
preserve_filename: bool = True,
|
|
142
|
+
prefer_frontmatter: bool = True,
|
|
143
|
+
) -> Item:
|
|
144
|
+
# First, try to resolve sidematter
|
|
145
|
+
sidematter = Sidematter(path).resolve(use_frontmatter=False)
|
|
146
|
+
|
|
147
|
+
# Use sidematter metadata unless we find and prefer frontmatter for metadata.
|
|
106
148
|
has_frontmatter = fmf_has_frontmatter(path)
|
|
107
|
-
|
|
108
|
-
if
|
|
149
|
+
frontmatter_meta = prefer_frontmatter and has_frontmatter and fmf_read_frontmatter(path)
|
|
150
|
+
if sidematter.meta and not frontmatter_meta:
|
|
151
|
+
metadata = sidematter.meta
|
|
152
|
+
body, _frontmatter_metadata = fmf_read(path)
|
|
153
|
+
log.debug(
|
|
154
|
+
"Read item from sidematter %s: body length %s, metadata %s",
|
|
155
|
+
sidematter.meta_path,
|
|
156
|
+
len(body),
|
|
157
|
+
metadata,
|
|
158
|
+
)
|
|
159
|
+
elif has_frontmatter:
|
|
109
160
|
body, metadata = fmf_read(path)
|
|
110
|
-
log.debug(
|
|
161
|
+
log.debug(
|
|
162
|
+
"Read item from %s: body length %s, metadata %s",
|
|
163
|
+
path,
|
|
164
|
+
len(body),
|
|
165
|
+
metadata,
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
# Not readable, binary or otherwise.
|
|
169
|
+
metadata = None
|
|
170
|
+
body = None
|
|
111
171
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
172
|
+
path = path.resolve()
|
|
173
|
+
if base_dir:
|
|
174
|
+
base_dir = base_dir.resolve()
|
|
115
175
|
|
|
116
176
|
# Ensure store_path is used if it's within the base_dir, and
|
|
117
177
|
# external_path otherwise.
|
|
@@ -128,7 +188,8 @@ def _read_item_uncached(path: Path, base_dir: Path | None) -> Item:
|
|
|
128
188
|
metadata, body=body, store_path=store_path, external_path=external_path
|
|
129
189
|
)
|
|
130
190
|
else:
|
|
131
|
-
# This is a file without frontmatter
|
|
191
|
+
# This is a file without frontmatter or sidematter.
|
|
192
|
+
# Infer format from the file and content,
|
|
132
193
|
# and use store_path or external_path as appropriate.
|
|
133
194
|
item = Item.from_external_path(path)
|
|
134
195
|
if item.format:
|
|
@@ -154,6 +215,10 @@ def _read_item_uncached(path: Path, base_dir: Path | None) -> Item:
|
|
|
154
215
|
item.body = f.read()
|
|
155
216
|
item.external_path = None
|
|
156
217
|
|
|
218
|
+
# Preserve the original filename.
|
|
219
|
+
if preserve_filename:
|
|
220
|
+
item.original_filename = path.name
|
|
221
|
+
|
|
157
222
|
# Update modified time.
|
|
158
223
|
item.set_modified(path.stat().st_mtime)
|
|
159
224
|
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
|
|
6
|
+
from prettyfmt import fmt_lines, fmt_path
|
|
7
|
+
|
|
8
|
+
from kash.config.logger import get_logger
|
|
9
|
+
from kash.file_storage.store_filenames import join_suffix, parse_item_filename
|
|
10
|
+
from kash.model.items_model import Item, ItemId
|
|
11
|
+
from kash.model.paths_model import StorePath
|
|
12
|
+
from kash.utils.common.uniquifier import Uniquifier
|
|
13
|
+
from kash.utils.errors import InvalidFilename, SkippableError
|
|
14
|
+
|
|
15
|
+
log = get_logger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ItemIdIndex:
|
|
19
|
+
"""
|
|
20
|
+
Index of item identities and historical filenames within a workspace.
|
|
21
|
+
|
|
22
|
+
- Tracks a mapping of `ItemId -> StorePath` for quick lookups
|
|
23
|
+
- Tracks historical slugs via `Uniquifier` to generate unique names consistently
|
|
24
|
+
|
|
25
|
+
TODO: Should add a file system watcher to make this always consistent with disk state.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self) -> None:
|
|
29
|
+
self._lock = threading.RLock()
|
|
30
|
+
self.uniquifier = Uniquifier()
|
|
31
|
+
self.id_map: dict[ItemId, StorePath] = {}
|
|
32
|
+
|
|
33
|
+
def reset(self) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Clear all index state.
|
|
36
|
+
"""
|
|
37
|
+
with self._lock:
|
|
38
|
+
log.info("ItemIdIndex: reset")
|
|
39
|
+
self.uniquifier = Uniquifier()
|
|
40
|
+
self.id_map.clear()
|
|
41
|
+
|
|
42
|
+
def __len__(self) -> int:
|
|
43
|
+
"""
|
|
44
|
+
Number of unique names tracked.
|
|
45
|
+
"""
|
|
46
|
+
with self._lock:
|
|
47
|
+
return len(self.uniquifier)
|
|
48
|
+
|
|
49
|
+
def uniquify_slug(self, slug: str, full_suffix: str) -> tuple[str, list[str]]:
|
|
50
|
+
"""
|
|
51
|
+
Return a unique slug and historic slugs for the given suffix.
|
|
52
|
+
"""
|
|
53
|
+
with self._lock:
|
|
54
|
+
# This updates internal history as a side-effect. Log for consistency.
|
|
55
|
+
log.info("ItemIdIndex: uniquify slug '%s' with suffix '%s'", slug, full_suffix)
|
|
56
|
+
return self.uniquifier.uniquify_historic(slug, full_suffix)
|
|
57
|
+
|
|
58
|
+
def index_item(
|
|
59
|
+
self, store_path: StorePath, load_item: Callable[[StorePath], Item]
|
|
60
|
+
) -> StorePath | None:
|
|
61
|
+
"""
|
|
62
|
+
Update the index with an item at `store_path`.
|
|
63
|
+
Returns store path of any duplicate item with the same id, otherwise None.
|
|
64
|
+
"""
|
|
65
|
+
name, item_type, _format, file_ext = parse_item_filename(store_path)
|
|
66
|
+
if not file_ext:
|
|
67
|
+
log.debug(
|
|
68
|
+
"Skipping file with unrecognized name or extension: %s",
|
|
69
|
+
fmt_path(store_path),
|
|
70
|
+
)
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
with self._lock:
|
|
74
|
+
full_suffix = join_suffix(item_type.name, file_ext.name) if item_type else file_ext.name
|
|
75
|
+
# Track unique name history
|
|
76
|
+
self.uniquifier.add(name, full_suffix)
|
|
77
|
+
|
|
78
|
+
log.info("ItemIdIndex: indexing %s", fmt_path(store_path))
|
|
79
|
+
|
|
80
|
+
# Load item outside the lock to avoid holding it during potentially slow I/O
|
|
81
|
+
try:
|
|
82
|
+
item = load_item(store_path)
|
|
83
|
+
except (ValueError, SkippableError) as e:
|
|
84
|
+
log.warning(
|
|
85
|
+
"ItemIdIndex: could not index file, skipping: %s: %s",
|
|
86
|
+
fmt_path(store_path),
|
|
87
|
+
e,
|
|
88
|
+
)
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
dup_path: StorePath | None = None
|
|
92
|
+
with self._lock:
|
|
93
|
+
item_id = item.item_id()
|
|
94
|
+
if item_id:
|
|
95
|
+
old_path = self.id_map.get(item_id)
|
|
96
|
+
if old_path and old_path != store_path:
|
|
97
|
+
dup_path = old_path
|
|
98
|
+
log.info(
|
|
99
|
+
"ItemIdIndex: duplicate id detected %s:\n%s",
|
|
100
|
+
item_id,
|
|
101
|
+
fmt_lines([old_path, store_path]),
|
|
102
|
+
)
|
|
103
|
+
self.id_map[item_id] = store_path
|
|
104
|
+
log.info("ItemIdIndex: set id %s -> %s", item_id, fmt_path(store_path))
|
|
105
|
+
|
|
106
|
+
return dup_path
|
|
107
|
+
|
|
108
|
+
def unindex_item(self, store_path: StorePath, load_item: Callable[[StorePath], Item]) -> None:
|
|
109
|
+
"""
|
|
110
|
+
Remove an item from the id index.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
# Load item outside the lock to avoid holding it during potentially slow I/O
|
|
114
|
+
item = load_item(store_path)
|
|
115
|
+
item_id = item.item_id()
|
|
116
|
+
if item_id:
|
|
117
|
+
with self._lock:
|
|
118
|
+
try:
|
|
119
|
+
self.id_map.pop(item_id, None)
|
|
120
|
+
log.info("ItemIdIndex: removed id %s for %s", item_id, fmt_path(store_path))
|
|
121
|
+
except KeyError:
|
|
122
|
+
pass
|
|
123
|
+
except (FileNotFoundError, InvalidFilename):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
def find_store_path_by_id(self, item_id: ItemId) -> StorePath | None:
|
|
127
|
+
with self._lock:
|
|
128
|
+
return self.id_map.get(item_id)
|
kash/help/help_types.py
CHANGED
|
@@ -5,7 +5,7 @@ from dataclasses import dataclass
|
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from typing import TYPE_CHECKING, ClassVar
|
|
7
7
|
|
|
8
|
-
from flowmark
|
|
8
|
+
from flowmark import split_sentences_regex
|
|
9
9
|
from rich.console import Group
|
|
10
10
|
from rich.text import Text
|
|
11
11
|
from strif import abbrev_str, single_line
|
kash/llm_utils/llms.py
CHANGED
|
@@ -13,6 +13,10 @@ class LLM(LLMName, Enum):
|
|
|
13
13
|
"""
|
|
14
14
|
|
|
15
15
|
# https://platform.openai.com/docs/models
|
|
16
|
+
gpt_5 = LLMName("gpt-5")
|
|
17
|
+
gpt_5_mini = LLMName("gpt-5-mini")
|
|
18
|
+
gpt_5_nano = LLMName("gpt-5-nano")
|
|
19
|
+
gpt_5_chat = LLMName("gpt-5-chat")
|
|
16
20
|
o4_mini = LLMName("o4-mini")
|
|
17
21
|
o3 = LLMName("o3")
|
|
18
22
|
o3_pro = LLMName("o3-pro")
|
|
@@ -25,11 +29,12 @@ class LLM(LLMName, Enum):
|
|
|
25
29
|
gpt_4o = LLMName("gpt-4o")
|
|
26
30
|
gpt_4o_mini = LLMName("gpt-4o-mini")
|
|
27
31
|
gpt_4 = LLMName("gpt-4")
|
|
28
|
-
|
|
29
32
|
gpt_4_1_mini = LLMName("gpt-4.1-mini")
|
|
30
33
|
gpt_4_1_nano = LLMName("gpt-4.1-nano")
|
|
31
34
|
|
|
32
35
|
# https://docs.anthropic.com/en/docs/about-claude/models/all-models
|
|
36
|
+
|
|
37
|
+
claude_4_1_opus = LLMName("claude-opus-4-1")
|
|
33
38
|
claude_4_opus = LLMName("claude-opus-4-20250514")
|
|
34
39
|
claude_4_sonnet = LLMName("claude-sonnet-4-20250514")
|
|
35
40
|
claude_3_7_sonnet = LLMName("claude-3-7-sonnet-latest")
|
|
@@ -49,7 +49,8 @@ def local_server_logs(follow: bool = False) -> None:
|
|
|
49
49
|
"""
|
|
50
50
|
Show the logs from the kash local (UI and MCP) servers.
|
|
51
51
|
|
|
52
|
-
:
|
|
52
|
+
Args:
|
|
53
|
+
follow: Follow the file as it grows.
|
|
53
54
|
"""
|
|
54
55
|
log_path = global_settings().local_server_log_path
|
|
55
56
|
if not log_path.exists():
|
kash/mcp/mcp_server_commands.py
CHANGED
|
@@ -54,8 +54,9 @@ def mcp_logs(follow: bool = False, all: bool = False) -> None:
|
|
|
54
54
|
"""
|
|
55
55
|
Show the logs from the MCP server and CLI proxy process.
|
|
56
56
|
|
|
57
|
-
:
|
|
58
|
-
|
|
57
|
+
Args:
|
|
58
|
+
follow: Follow the file as it grows.
|
|
59
|
+
all: Show all logs, not just the server logs, including Claude Desktop logs if found.
|
|
59
60
|
"""
|
|
60
61
|
from kash.mcp.mcp_cli import MCP_CLI_LOG_PATH
|
|
61
62
|
|
kash/mcp/mcp_server_routes.py
CHANGED
|
@@ -6,8 +6,10 @@ from dataclasses import dataclass
|
|
|
6
6
|
|
|
7
7
|
from funlog import log_calls
|
|
8
8
|
from mcp.server.lowlevel import Server
|
|
9
|
+
from mcp.server.lowlevel.server import StructuredContent, UnstructuredContent
|
|
9
10
|
from mcp.types import Prompt, Resource, TextContent, Tool
|
|
10
11
|
from prettyfmt import fmt_path
|
|
12
|
+
from pydantic import BaseModel
|
|
11
13
|
from strif import AtomicVar
|
|
12
14
|
|
|
13
15
|
from kash.config.capture_output import CapturedOutput, captured_output
|
|
@@ -20,6 +22,7 @@ from kash.model.actions_model import Action, ActionResult
|
|
|
20
22
|
from kash.model.exec_model import ExecContext
|
|
21
23
|
from kash.model.params_model import TypedParamValues
|
|
22
24
|
from kash.model.paths_model import StorePath
|
|
25
|
+
from kash.utils.common.url import Url
|
|
23
26
|
|
|
24
27
|
log = get_logger(__name__)
|
|
25
28
|
|
|
@@ -109,6 +112,22 @@ def get_published_tools() -> list[Tool]:
|
|
|
109
112
|
return []
|
|
110
113
|
|
|
111
114
|
|
|
115
|
+
class StructuredActionResult(BaseModel):
|
|
116
|
+
"""
|
|
117
|
+
Error from an MCP tool call.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
s3_paths: list[Url] | None = None
|
|
121
|
+
"""If the tool created an S3 item, the S3 paths of the created items."""
|
|
122
|
+
|
|
123
|
+
error: str | None = None
|
|
124
|
+
"""If the tool had an error, the error message."""
|
|
125
|
+
|
|
126
|
+
# TODO: Include other metadata.
|
|
127
|
+
# metadata: dict[str, Any] | None = None
|
|
128
|
+
# """Metadata about the action result."""
|
|
129
|
+
|
|
130
|
+
|
|
112
131
|
@dataclass(frozen=True)
|
|
113
132
|
class ToolResult:
|
|
114
133
|
"""
|
|
@@ -119,6 +138,7 @@ class ToolResult:
|
|
|
119
138
|
captured_output: CapturedOutput
|
|
120
139
|
action_result: ActionResult
|
|
121
140
|
result_store_paths: list[StorePath]
|
|
141
|
+
result_s3_paths: list[Url]
|
|
122
142
|
error: Exception | None = None
|
|
123
143
|
|
|
124
144
|
@property
|
|
@@ -168,12 +188,13 @@ class ToolResult:
|
|
|
168
188
|
# TODO: Add more info on how to find the logs.
|
|
169
189
|
return "Check kash logs for details."
|
|
170
190
|
|
|
171
|
-
def
|
|
191
|
+
def as_mcp_content(self) -> tuple[UnstructuredContent, StructuredContent]:
|
|
172
192
|
"""
|
|
173
|
-
Convert the tool result to content for the client
|
|
193
|
+
Convert the tool result to content for the MCP client.
|
|
174
194
|
"""
|
|
195
|
+
structured = StructuredActionResult()
|
|
175
196
|
if self.error:
|
|
176
|
-
|
|
197
|
+
unstructured = [
|
|
177
198
|
TextContent(
|
|
178
199
|
text=f"The tool `{self.action.name}` had an error: {self.error}.\n\n"
|
|
179
200
|
+ self.check_logs_message,
|
|
@@ -194,7 +215,7 @@ class ToolResult:
|
|
|
194
215
|
if not chat_result:
|
|
195
216
|
chat_result = "No result. Check kash logs for details."
|
|
196
217
|
|
|
197
|
-
|
|
218
|
+
unstructured = [
|
|
198
219
|
TextContent(
|
|
199
220
|
text=f"{self.output_summary}\n\n"
|
|
200
221
|
f"{self.output_content}\n\n"
|
|
@@ -202,10 +223,15 @@ class ToolResult:
|
|
|
202
223
|
type="text",
|
|
203
224
|
),
|
|
204
225
|
]
|
|
226
|
+
structured = StructuredActionResult(s3_paths=self.result_s3_paths)
|
|
227
|
+
|
|
228
|
+
return unstructured, structured.model_dump()
|
|
205
229
|
|
|
206
230
|
|
|
207
231
|
@log_calls(level="info")
|
|
208
|
-
def run_mcp_tool(
|
|
232
|
+
def run_mcp_tool(
|
|
233
|
+
action_name: str, arguments: dict
|
|
234
|
+
) -> tuple[UnstructuredContent, StructuredContent]:
|
|
209
235
|
"""
|
|
210
236
|
Run the action as a tool.
|
|
211
237
|
"""
|
|
@@ -222,6 +248,7 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
222
248
|
refetch=False, # Using the file caches.
|
|
223
249
|
# Keeping all transient files for now, but maybe make transient?
|
|
224
250
|
override_state=None,
|
|
251
|
+
sync_to_s3=True, # Enable S3 syncing for MCP tools.
|
|
225
252
|
) as exec_settings:
|
|
226
253
|
action_cls = look_up_action_class(action_name)
|
|
227
254
|
|
|
@@ -237,9 +264,9 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
237
264
|
context = ExecContext(action=action, settings=exec_settings)
|
|
238
265
|
action_input = prepare_action_input(*input_items)
|
|
239
266
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
267
|
+
result_with_paths = run_action_with_caching(context, action_input)
|
|
268
|
+
result = result_with_paths.result
|
|
269
|
+
result_store_paths = result_with_paths.result_paths
|
|
243
270
|
|
|
244
271
|
# Return final result, formatted for the LLM to understand.
|
|
245
272
|
return ToolResult(
|
|
@@ -247,8 +274,9 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
247
274
|
captured_output=capture.output,
|
|
248
275
|
action_result=result,
|
|
249
276
|
result_store_paths=result_store_paths,
|
|
277
|
+
result_s3_paths=result_with_paths.s3_paths,
|
|
250
278
|
error=None,
|
|
251
|
-
).
|
|
279
|
+
).as_mcp_content()
|
|
252
280
|
|
|
253
281
|
except Exception as e:
|
|
254
282
|
log.exception("Error running mcp tool")
|
|
@@ -258,7 +286,7 @@ def run_mcp_tool(action_name: str, arguments: dict) -> list[TextContent]:
|
|
|
258
286
|
+ "Check kash logs for details.",
|
|
259
287
|
type="text",
|
|
260
288
|
)
|
|
261
|
-
]
|
|
289
|
+
], StructuredActionResult(error=str(e)).model_dump()
|
|
262
290
|
|
|
263
291
|
|
|
264
292
|
def create_base_server() -> Server:
|
|
@@ -288,7 +316,9 @@ def create_base_server() -> Server:
|
|
|
288
316
|
return []
|
|
289
317
|
|
|
290
318
|
@app.call_tool()
|
|
291
|
-
async def handle_tool(
|
|
319
|
+
async def handle_tool(
|
|
320
|
+
name: str, arguments: dict
|
|
321
|
+
) -> tuple[UnstructuredContent, StructuredContent]:
|
|
292
322
|
try:
|
|
293
323
|
if name not in _mcp_published_actions.copy():
|
|
294
324
|
log.error(f"Unknown tool requested: {name}")
|
|
@@ -303,6 +333,6 @@ def create_base_server() -> Server:
|
|
|
303
333
|
text=f"Error executing tool {name}: {e}",
|
|
304
334
|
type="text",
|
|
305
335
|
)
|
|
306
|
-
]
|
|
336
|
+
], StructuredActionResult(error=str(e)).model_dump()
|
|
307
337
|
|
|
308
338
|
return app
|
kash/model/actions_model.py
CHANGED
|
@@ -21,9 +21,8 @@ from kash.exec_model.args_model import NO_ARGS, ONE_ARG, ArgCount, ArgType, Sign
|
|
|
21
21
|
from kash.exec_model.shell_model import ShellResult
|
|
22
22
|
from kash.llm_utils import LLM, LLMName
|
|
23
23
|
from kash.llm_utils.llm_messages import Message, MessageTemplate
|
|
24
|
-
from kash.model.exec_model import ExecContext
|
|
24
|
+
from kash.model.exec_model import ActionContext, ExecContext
|
|
25
25
|
from kash.model.items_model import UNTITLED, Format, Item, ItemType
|
|
26
|
-
from kash.model.operations_model import Operation, Source
|
|
27
26
|
from kash.model.params_model import (
|
|
28
27
|
ALL_COMMON_PARAMS,
|
|
29
28
|
COMMON_SHELL_PARAMS,
|
|
@@ -61,6 +60,17 @@ class ActionInput:
|
|
|
61
60
|
"""An empty input, for when an action processes no items."""
|
|
62
61
|
return ActionInput(items=[])
|
|
63
62
|
|
|
63
|
+
# XXX For convenience, we have the ability to include the context on each item
|
|
64
|
+
# (this helps soper-item functions don't have to take context args everywhere).
|
|
65
|
+
# TODO: Probably better to move this to a context var.
|
|
66
|
+
def set_context(self, context: ActionContext) -> None:
|
|
67
|
+
for item in self.items:
|
|
68
|
+
item.context = context
|
|
69
|
+
|
|
70
|
+
def clear_context(self) -> None:
|
|
71
|
+
for item in self.items:
|
|
72
|
+
item.context = None
|
|
73
|
+
|
|
64
74
|
|
|
65
75
|
class PathOpType(Enum):
|
|
66
76
|
archive = "archive"
|
|
@@ -111,6 +121,10 @@ class ActionResult:
|
|
|
111
121
|
self.replaces_input or self.skip_duplicates or self.path_ops or self.shell_result
|
|
112
122
|
)
|
|
113
123
|
|
|
124
|
+
def set_context(self, context: ActionContext) -> None:
|
|
125
|
+
for item in self.items:
|
|
126
|
+
item.context = context
|
|
127
|
+
|
|
114
128
|
def clear_context(self):
|
|
115
129
|
for item in self.items:
|
|
116
130
|
item.context = None
|
|
@@ -232,7 +246,17 @@ class Action(ABC):
|
|
|
232
246
|
|
|
233
247
|
output_type: ItemType = ItemType.doc
|
|
234
248
|
"""
|
|
235
|
-
The type of the output item(s)
|
|
249
|
+
The type of the output item(s). If an action returns multiple output types,
|
|
250
|
+
this will be the output type of the first output.
|
|
251
|
+
This is mainly used for preassembly for the cache check if an output already exists.
|
|
252
|
+
"""
|
|
253
|
+
|
|
254
|
+
output_format: Format | None = None
|
|
255
|
+
"""
|
|
256
|
+
The format of the output item(s). The default is to assume it is the same
|
|
257
|
+
format as the input. If an action returns multiple output formats,
|
|
258
|
+
this will be the format of the first output.
|
|
259
|
+
This is mainly used for preassembly for the cache check if an output already exists.
|
|
236
260
|
"""
|
|
237
261
|
|
|
238
262
|
expected_outputs: ArgCount = ONE_ARG
|
|
@@ -505,33 +529,17 @@ class Action(ABC):
|
|
|
505
529
|
fmt_lines(overrides),
|
|
506
530
|
)
|
|
507
531
|
|
|
508
|
-
def
|
|
509
|
-
self,
|
|
510
|
-
operation: Operation,
|
|
511
|
-
input: ActionInput,
|
|
512
|
-
output_num: int,
|
|
513
|
-
type: ItemType,
|
|
514
|
-
**kwargs,
|
|
515
|
-
) -> Item:
|
|
532
|
+
def format_title(self, prev_title: str | None) -> str:
|
|
516
533
|
"""
|
|
517
|
-
|
|
518
|
-
type, and last Operation so we can do an identity check if the output already exists.
|
|
534
|
+
Format the title for an output item of this action.
|
|
519
535
|
"""
|
|
520
|
-
|
|
521
|
-
item = primary_input.derived_copy(type=type, body=None, **kwargs)
|
|
522
|
-
|
|
536
|
+
prev_title = prev_title or UNTITLED
|
|
523
537
|
if self.title_template:
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
item.update_history(
|
|
529
|
-
Source(operation=operation, output_num=output_num, cacheable=self.cacheable)
|
|
530
|
-
)
|
|
531
|
-
|
|
532
|
-
return item
|
|
538
|
+
return self.title_template.format(title=prev_title, action_name=self.name)
|
|
539
|
+
else:
|
|
540
|
+
return prev_title
|
|
533
541
|
|
|
534
|
-
def
|
|
542
|
+
def preassemble_result(self, context: ActionContext) -> ActionResult | None:
|
|
535
543
|
"""
|
|
536
544
|
Actions can have a separate preliminary step to pre-assemble outputs. This allows
|
|
537
545
|
us to determine the title and types for the output items and check if they were
|
|
@@ -542,16 +550,19 @@ class Action(ABC):
|
|
|
542
550
|
"""
|
|
543
551
|
can_preassemble = self.cacheable and self.expected_outputs == ONE_ARG
|
|
544
552
|
log.info(
|
|
545
|
-
"Preassemble check for `%s
|
|
553
|
+
"Preassemble check for `%s`: can_preassemble=%s (expected_outputs=%s, cacheable=%s)",
|
|
546
554
|
self.name,
|
|
547
555
|
can_preassemble,
|
|
548
556
|
self.expected_outputs,
|
|
549
557
|
self.cacheable,
|
|
550
558
|
)
|
|
551
559
|
if can_preassemble:
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
560
|
+
# Using first input to determine the output title.
|
|
561
|
+
primary_input = context.action_input.items[0]
|
|
562
|
+
# In this case we only expect one output, of the type specified by the action.
|
|
563
|
+
primary_output = primary_input.derived_copy(context, 0, type=context.action.output_type)
|
|
564
|
+
log.info("Preassembled output: source %s, %s", primary_output.source, primary_output)
|
|
565
|
+
return ActionResult([primary_output])
|
|
555
566
|
else:
|
|
556
567
|
# Caching disabled.
|
|
557
568
|
return None
|
|
@@ -600,7 +611,7 @@ class Action(ABC):
|
|
|
600
611
|
return input_schema
|
|
601
612
|
|
|
602
613
|
@abstractmethod
|
|
603
|
-
def run(self, input: ActionInput, context:
|
|
614
|
+
def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
|
|
604
615
|
pass
|
|
605
616
|
|
|
606
617
|
def __repr__(self):
|
|
@@ -626,7 +637,7 @@ class PerItemAction(Action, ABC):
|
|
|
626
637
|
super().__post_init__()
|
|
627
638
|
|
|
628
639
|
@override
|
|
629
|
-
def run(self, input: ActionInput, context:
|
|
640
|
+
def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
|
|
630
641
|
log.info("Running action `%s` per-item.", self.name)
|
|
631
642
|
for item in input.items:
|
|
632
643
|
item.context = context
|
|
@@ -642,3 +653,4 @@ class PerItemAction(Action, ABC):
|
|
|
642
653
|
# Handle circular dependency in Python dataclasses.
|
|
643
654
|
rebuild_dataclass(Item) # pyright: ignore
|
|
644
655
|
rebuild_dataclass(ExecContext) # pyright: ignore
|
|
656
|
+
rebuild_dataclass(ActionContext) # pyright: ignore
|