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.
Files changed (74) hide show
  1. kash/actions/core/chat.py +1 -0
  2. kash/actions/core/markdownify_html.py +4 -5
  3. kash/actions/core/minify_html.py +4 -5
  4. kash/actions/core/readability.py +1 -4
  5. kash/actions/core/render_as_html.py +10 -7
  6. kash/actions/core/save_sidematter_meta.py +47 -0
  7. kash/actions/core/show_webpage.py +2 -0
  8. kash/actions/core/zip_sidematter.py +47 -0
  9. kash/commands/base/basic_file_commands.py +7 -4
  10. kash/commands/base/diff_commands.py +6 -4
  11. kash/commands/base/files_command.py +31 -30
  12. kash/commands/base/general_commands.py +3 -2
  13. kash/commands/base/logs_commands.py +6 -4
  14. kash/commands/base/reformat_command.py +3 -2
  15. kash/commands/base/search_command.py +4 -3
  16. kash/commands/base/show_command.py +9 -7
  17. kash/commands/help/assistant_commands.py +6 -4
  18. kash/commands/help/help_commands.py +7 -4
  19. kash/commands/workspace/selection_commands.py +18 -16
  20. kash/commands/workspace/workspace_commands.py +39 -26
  21. kash/config/logger.py +1 -1
  22. kash/config/setup.py +2 -27
  23. kash/config/text_styles.py +1 -1
  24. kash/docs/markdown/topics/a1_what_is_kash.md +26 -18
  25. kash/docs/markdown/topics/a2_installation.md +3 -2
  26. kash/exec/action_decorators.py +7 -5
  27. kash/exec/action_exec.py +104 -53
  28. kash/exec/fetch_url_items.py +40 -11
  29. kash/exec/llm_transforms.py +14 -5
  30. kash/exec/preconditions.py +2 -2
  31. kash/exec/resolve_args.py +4 -1
  32. kash/exec/runtime_settings.py +3 -0
  33. kash/file_storage/file_store.py +108 -114
  34. kash/file_storage/item_file_format.py +91 -26
  35. kash/file_storage/item_id_index.py +128 -0
  36. kash/help/help_types.py +1 -1
  37. kash/llm_utils/llms.py +6 -1
  38. kash/local_server/local_server_commands.py +2 -1
  39. kash/mcp/mcp_server_commands.py +3 -2
  40. kash/mcp/mcp_server_routes.py +42 -12
  41. kash/model/actions_model.py +44 -32
  42. kash/model/compound_actions_model.py +4 -3
  43. kash/model/exec_model.py +33 -3
  44. kash/model/items_model.py +150 -60
  45. kash/model/params_model.py +4 -4
  46. kash/shell/output/shell_output.py +1 -2
  47. kash/utils/api_utils/gather_limited.py +2 -0
  48. kash/utils/api_utils/multitask_gather.py +74 -0
  49. kash/utils/common/s3_utils.py +108 -0
  50. kash/utils/common/url.py +16 -4
  51. kash/utils/file_formats/chat_format.py +7 -4
  52. kash/utils/file_utils/file_ext.py +1 -0
  53. kash/utils/file_utils/file_formats.py +4 -2
  54. kash/utils/file_utils/file_formats_model.py +12 -0
  55. kash/utils/text_handling/doc_normalization.py +1 -1
  56. kash/utils/text_handling/markdown_footnotes.py +224 -0
  57. kash/utils/text_handling/markdown_utils.py +532 -41
  58. kash/utils/text_handling/markdownify_utils.py +2 -1
  59. kash/web_content/web_fetch.py +2 -1
  60. kash/web_gen/templates/components/tooltip_scripts.js.jinja +186 -1
  61. kash/web_gen/templates/components/youtube_popover_scripts.js.jinja +223 -0
  62. kash/web_gen/templates/components/youtube_popover_styles.css.jinja +150 -0
  63. kash/web_gen/templates/content_styles.css.jinja +53 -1
  64. kash/web_gen/templates/youtube_webpage.html.jinja +47 -0
  65. kash/web_gen/webpage_render.py +103 -0
  66. kash/workspaces/workspaces.py +0 -5
  67. kash/xonsh_custom/custom_shell.py +4 -3
  68. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/METADATA +35 -26
  69. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/RECORD +72 -64
  70. kash/llm_utils/llm_features.py +0 -72
  71. kash/web_gen/simple_webpage.py +0 -55
  72. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/WHEEL +0 -0
  73. {kash_shell-0.3.28.dist-info → kash_shell-0.3.33.dist-info}/entry_points.txt +0 -0
  74. {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 FmStyle, fmf_has_frontmatter, fmf_read, fmf_write
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
- fmf_write(
69
- path,
70
- body,
71
- item.metadata(),
72
- style=fm_style,
73
- key_sort=ITEM_FIELD_SORT,
74
- make_parents=True,
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's body to reflect normalization.
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 an item from a file. Uses `base_dir` to resolve paths, so the item's
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
- If frontmatter format YAML is present, it is parsed. If not, the item will
90
- be a resource with a format inferred from the file extension or the content.
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(path: Path, base_dir: Path | None) -> Item:
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
- body = metadata = None
108
- if has_frontmatter:
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("Read item from %s: body length %s, metadata %s", path, len(body), metadata)
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
- path = path.resolve()
113
- if base_dir:
114
- base_dir = base_dir.resolve()
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. Infer format from the file and content,
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.sentence_split_regex import split_sentences_regex
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
- :param follow: Follow the file as it grows.
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():
@@ -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
- :param follow: Follow the file as it grows.
58
- :param all: Show all logs, not just the server logs, including Claude Desktop logs if found.
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
 
@@ -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 formatted_for_client(self) -> list[TextContent]:
191
+ def as_mcp_content(self) -> tuple[UnstructuredContent, StructuredContent]:
172
192
  """
173
- Convert the tool result to content for the client LLM.
193
+ Convert the tool result to content for the MCP client.
174
194
  """
195
+ structured = StructuredActionResult()
175
196
  if self.error:
176
- return [
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
- return [
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(action_name: str, arguments: dict) -> list[TextContent]:
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
- result, result_store_paths, _archived_store_paths = run_action_with_caching(
241
- context=context, action_input=action_input
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
- ).formatted_for_client()
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(name: str, arguments: dict) -> list[TextContent]:
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
@@ -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), which for now are all assumed to be of the same type.
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 _preassemble_one(
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
- Preassemble a single empty output item from the given input items. Include the title,
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
- primary_input = input.items[output_num]
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
- item.title = self.title_template.format(
525
- title=primary_input.title or UNTITLED, action_name=self.name
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 preassemble(self, operation: Operation, input: ActionInput) -> ActionResult | None:
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` is %s (%s with cacheable=%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
- return ActionResult(
553
- [self._preassemble_one(operation, input, output_num=0, type=self.output_type)]
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: ExecContext) -> ActionResult:
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: ExecContext) -> ActionResult:
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