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
@@ -5,7 +5,8 @@ from pydantic.dataclasses import dataclass
5
5
 
6
6
  from kash.config.logger import get_logger
7
7
  from kash.exec.combiners import Combiner
8
- from kash.model.actions_model import Action, ActionInput, ActionResult, ExecContext
8
+ from kash.model.actions_model import Action, ActionInput, ActionResult
9
+ from kash.model.exec_model import ActionContext
9
10
  from kash.model.items_model import Item, State
10
11
  from kash.model.params_model import RawParamValues
11
12
  from kash.model.paths_model import StorePath
@@ -46,7 +47,7 @@ class SequenceAction(Action):
46
47
  )
47
48
  self.description = seq_description
48
49
 
49
- def run(self, input: ActionInput, context: ExecContext) -> ActionResult:
50
+ def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
50
51
  from kash.exec.action_exec import run_action_with_shell_context
51
52
  from kash.workspaces import current_ws
52
53
 
@@ -140,7 +141,7 @@ class ComboAction(Action):
140
141
 
141
142
  self.description = combo_description
142
143
 
143
- def run(self, input: ActionInput, context: ExecContext) -> ActionResult:
144
+ def run(self, input: ActionInput, context: ActionContext) -> ActionResult:
144
145
  from kash.exec.action_exec import run_action_with_shell_context
145
146
  from kash.exec.combiners import combine_as_paragraphs
146
147
 
kash/model/exec_model.py CHANGED
@@ -8,10 +8,11 @@ from pydantic.dataclasses import dataclass
8
8
 
9
9
  from kash.config.logger import get_logger
10
10
  from kash.model.items_model import State
11
+ from kash.model.operations_model import Operation
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from kash.file_storage.file_store import FileStore
14
- from kash.model.actions_model import Action
15
+ from kash.model.actions_model import Action, ActionInput
15
16
 
16
17
 
17
18
  log = get_logger(__name__)
@@ -42,6 +43,9 @@ class RuntimeSettings:
42
43
  no_format: bool = False
43
44
  """If True, will not normalize the output item's body text formatting (for Markdown)."""
44
45
 
46
+ sync_to_s3: bool = True
47
+ """If True, will sync output items to S3 if input was from S3."""
48
+
45
49
  @property
46
50
  def workspace(self) -> FileStore:
47
51
  from kash.workspaces.workspaces import get_ws
@@ -68,8 +72,8 @@ class RuntimeSettings:
68
72
  @dataclass(frozen=True)
69
73
  class ExecContext:
70
74
  """
71
- An action and its context for execution. This is a good place for settings
72
- that apply to any action and are bothersome to pass as parameters.
75
+ An action and its general context for execution. This is a good place for general
76
+ settings that apply to any action and are bothersome to pass as parameters.
73
77
  """
74
78
 
75
79
  action: Action
@@ -77,3 +81,29 @@ class ExecContext:
77
81
 
78
82
  settings: RuntimeSettings
79
83
  """The workspace and other run-time settings for the action."""
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ActionContext:
88
+ """
89
+ All context for the currently executing action, with all inputs and options.
90
+ """
91
+
92
+ exec_context: ExecContext
93
+ """The context of the current execution."""
94
+
95
+ action_input: ActionInput
96
+ """The assembled input to the current action."""
97
+
98
+ operation: Operation
99
+ """The operation in full detail, including inputs and options."""
100
+
101
+ @property
102
+ def action(self) -> Action:
103
+ """The action being executed."""
104
+ return self.exec_context.action
105
+
106
+ @property
107
+ def settings(self) -> RuntimeSettings:
108
+ """The workspace and other run-time settings for the action."""
109
+ return self.exec_context.settings
kash/model/items_model.py CHANGED
@@ -17,7 +17,7 @@ from prettyfmt import (
17
17
  slugify_snake,
18
18
  )
19
19
  from pydantic.dataclasses import dataclass
20
- from strif import abbrev_str, format_iso_timestamp
20
+ from strif import abbrev_str, format_iso_timestamp, single_line
21
21
 
22
22
  from kash.config.logger import get_logger
23
23
  from kash.model.concept_model import canonicalize_concept
@@ -34,7 +34,9 @@ from kash.utils.text_handling.markdown_render import markdown_to_html
34
34
  from kash.utils.text_handling.markdown_utils import first_heading
35
35
 
36
36
  if TYPE_CHECKING:
37
- from kash.model.exec_model import ExecContext
37
+ from sidematter_format import ResolvedSidematter
38
+
39
+ from kash.model.exec_model import ActionContext
38
40
  from kash.workspaces import Workspace
39
41
 
40
42
  log = get_logger(__name__)
@@ -68,7 +70,15 @@ class ItemType(Enum):
68
70
  """
69
71
  Resources don't have a body. On concepts it's optional.
70
72
  """
71
- return self.value not in [ItemType.resource.value, ItemType.concept.value]
73
+ return self.value not in (ItemType.resource.value, ItemType.concept.value)
74
+
75
+ @property
76
+ def allows_op_suffix(self) -> bool:
77
+ """
78
+ Whether it makes sense to have an operation suffix for this item type
79
+ (docs often should, but concepts or resources can have cleaner naming conventions).
80
+ """
81
+ return self not in (ItemType.concept, ItemType.resource, ItemType.export)
72
82
 
73
83
  @staticmethod
74
84
  def for_format(format: Format) -> ItemType:
@@ -100,6 +110,7 @@ class ItemType(Enum):
100
110
  Format.mp3: ItemType.resource,
101
111
  Format.m4a: ItemType.resource,
102
112
  Format.mp4: ItemType.resource,
113
+ Format.zip: ItemType.resource,
103
114
  }
104
115
  return format_to_item_type.get(format, ItemType.resource)
105
116
 
@@ -192,6 +203,15 @@ class ItemId:
192
203
  # If we got here, the item has no identity.
193
204
  item_id = None
194
205
 
206
+ log.debug(
207
+ "item_id is %s for type=%s, format=%s, url=%s, title=%s, source=%s",
208
+ item_id,
209
+ item.type,
210
+ item.format,
211
+ item.url,
212
+ item.title,
213
+ item.source,
214
+ )
195
215
  return item_id
196
216
 
197
217
 
@@ -267,7 +287,7 @@ class Item:
267
287
 
268
288
  # Optional execution context. Useful for letting functions that take only an Item
269
289
  # arg get access to context.
270
- context: ExecContext | None = field(default=None, metadata={"exclude": True})
290
+ context: ActionContext | None = field(default=None, metadata={"exclude": True})
271
291
 
272
292
  # These fields we don't want in YAML frontmatter.
273
293
  # We don't include store_path as it's redundant with the filename.
@@ -369,9 +389,9 @@ class Item:
369
389
  item_type: ItemType | None = None,
370
390
  *,
371
391
  title: str | None = None,
372
- original_filename: str | None = None,
373
392
  url: Url | None = None,
374
393
  mime_type: MimeType | None = None,
394
+ preserve_filename: bool = True,
375
395
  ) -> Item:
376
396
  """
377
397
  Create a resource Item for a file with a format inferred from the file extension
@@ -400,9 +420,10 @@ class Item:
400
420
  if not file_ext:
401
421
  file_ext = format_info.suggested_file_ext
402
422
 
423
+ original_filename = Path(path).name if preserve_filename else None
403
424
  item = cls(
404
425
  type=item_type,
405
- title=title,
426
+ title=single_line(title) if title else None, # Avoid multiline titles.
406
427
  file_ext=file_ext,
407
428
  format=format,
408
429
  external_path=str(path),
@@ -428,7 +449,7 @@ class Item:
428
449
  return cls(
429
450
  type=ItemType.resource,
430
451
  format=Format.url,
431
- title=media_metadata.title,
452
+ title=single_line(media_metadata.title), # Avoid multiline titles.
432
453
  url=media_metadata.url,
433
454
  description=media_metadata.description,
434
455
  thumbnail_url=media_metadata.thumbnail_url,
@@ -453,7 +474,7 @@ class Item:
453
474
  if self.type.expects_body and self.format.has_body and not self.body:
454
475
  raise ValueError(f"Item type `{self.type.value}` is text but has no body: {self}")
455
476
 
456
- def absolute_path(self, ws: Workspace | None = None) -> Path:
477
+ def absolute_path(self, ws: Path | Workspace | None = None) -> Path:
457
478
  """
458
479
  Get the absolute path to the item. Throws `ValueError` if the item has no
459
480
  store path. If no workspace is provided, uses the current workspace.
@@ -462,8 +483,11 @@ class Item:
462
483
 
463
484
  if not self.store_path:
464
485
  raise ValueError("Item has no store path")
465
- if not ws:
486
+ elif isinstance(ws, Path):
487
+ return ws / self.store_path
488
+ elif not ws:
466
489
  ws = current_ws()
490
+
467
491
  return ws.base_dir / self.store_path
468
492
 
469
493
  @property
@@ -485,7 +509,7 @@ class Item:
485
509
  raise ValueError("Cannot get doc id for an item that has not been saved")
486
510
  return str(self.store_path)
487
511
 
488
- def metadata(self, datetime_as_str: bool = False) -> dict[str, Any]:
512
+ def metadata(self, *, datetime_as_str: bool = False) -> dict[str, Any]:
489
513
  """
490
514
  Metadata is all relevant non-None fields in easy-to-serialize form.
491
515
  Optional fields are omitted unless they are set.
@@ -529,6 +553,15 @@ class Item:
529
553
 
530
554
  return item_dict
531
555
 
556
+ def sidematter(self, ws: Path | Workspace | None = None) -> ResolvedSidematter:
557
+ """
558
+ Get the sidematter for this item, if present, by looking at the files
559
+ in the specified workspace (or the current workspace if not specified).
560
+ """
561
+ from sidematter_format import Sidematter
562
+
563
+ return Sidematter(self.absolute_path(ws)).resolve()
564
+
532
565
  def filename_stem(self) -> str | None:
533
566
  """
534
567
  If the item has an existing or previous filename, return its stem,
@@ -545,16 +578,25 @@ class Item:
545
578
  path_name = None
546
579
  return path_name
547
580
 
548
- def slug_name(self, max_len: int = SLUG_MAX_LEN, prefer_title: bool = False) -> str:
581
+ def slug_name(
582
+ self,
583
+ max_len: int = SLUG_MAX_LEN,
584
+ prefer_title: bool = False,
585
+ add_ops_suffix: bool = True,
586
+ ) -> str:
549
587
  """
550
588
  Get a readable slugified name for this item, either from a previous filename
551
- or from slugifying the title or content. May not be unique.
589
+ or from slugifying the title or content. Adds the last operation suffix if requested
590
+ and available in item history.
552
591
  """
553
- filename_stem = self.filename_stem()
554
- if filename_stem and not prefer_title:
555
- return slugify_snake(filename_stem)
592
+ base_title = self.filename_stem()
593
+ if prefer_title or not base_title:
594
+ base_title = self.pick_title(max_len=max_len, add_ops_suffix=add_ops_suffix)
556
595
  else:
557
- return slugify_snake(self.pick_title(max_len=max_len, add_ops_suffix=True))
596
+ base_title = self.pick_title(
597
+ base_title=base_title, max_len=max_len, add_ops_suffix=add_ops_suffix
598
+ )
599
+ return slugify_snake(base_title)
558
600
 
559
601
  def default_filename(self) -> str:
560
602
  """
@@ -578,6 +620,7 @@ class Item:
578
620
 
579
621
  def pick_title(
580
622
  self,
623
+ base_title: str | None = None,
581
624
  *,
582
625
  max_len: int = 100,
583
626
  add_ops_suffix: bool = False,
@@ -590,35 +633,31 @@ class Item:
590
633
  """
591
634
  # First special case: if we are pulling the title from the body header, check
592
635
  # that.
593
- if not self.title and pull_body_heading:
594
- heading = self.body_heading()
595
- if heading:
596
- return heading
597
-
598
- # Next special case: URLs with no title use the url itself.
599
- if not self.title and self.url:
600
- return abbrev_str(self.url, max_len)
601
-
602
- filename_stem = self.filename_stem()
603
-
604
- # Use the title or the path if possible, falling back to description or even body text.
605
- title_raw_text = (
606
- self.title
607
- or filename_stem
608
- or self.description
609
- or (not self.is_binary and self.abbrev_body(max_len))
610
- or UNTITLED
611
- )
636
+ if not base_title:
637
+ if not base_title and pull_body_heading:
638
+ heading = self.body_heading()
639
+ base_title = heading
640
+
641
+ # Next special case: URLs with no title use the url itself.
642
+ if not self.title and self.url:
643
+ return abbrev_str(self.url, max_len)
644
+
645
+ filename_stem = self.filename_stem()
646
+
647
+ # Use the title or the path if possible, falling back to description or even body text.
648
+ base_title = (
649
+ self.title
650
+ or filename_stem
651
+ or self.description
652
+ or (not self.is_binary and self.abbrev_body(max_len))
653
+ or UNTITLED
654
+ )
612
655
 
613
- suffix = ""
614
656
  # For docs, etc but not for concepts/resources/exports, add a parenthical note
615
657
  # indicating the last operation, if there was one. This makes filename slugs
616
658
  # more readable.
617
- if add_ops_suffix and self.type not in [
618
- ItemType.concept,
619
- ItemType.resource,
620
- ItemType.export,
621
- ]:
659
+ suffix = ""
660
+ if add_ops_suffix and self.type.allows_op_suffix:
622
661
  last_op = self.history and self.history[-1].action_name
623
662
  if last_op:
624
663
  step_num = len(self.history) + 1 if self.history else 1
@@ -626,7 +665,7 @@ class Item:
626
665
 
627
666
  shorter_len = min(max_len, max(max_len - len(suffix), 20))
628
667
  clean_text = sanitize_title(
629
- abbrev_phrase_in_middle(html_to_plaintext(title_raw_text), shorter_len)
668
+ abbrev_phrase_in_middle(html_to_plaintext(base_title), shorter_len)
630
669
  )
631
670
 
632
671
  final_text = clean_text
@@ -670,8 +709,6 @@ class Item:
670
709
  """
671
710
  If it is a data Item, return the parsed YAML.
672
711
  """
673
- if not self.type == ItemType.data:
674
- raise FileFormatError(f"Item is not a data item: {self}")
675
712
  if not self.body:
676
713
  raise FileFormatError(f"Data item has no body: {self}")
677
714
  if self.format != Format.yaml:
@@ -795,15 +832,24 @@ class Item:
795
832
  merged_fields = self._copy_and_update(other, update_timestamp=False)
796
833
  return Item(**merged_fields)
797
834
 
798
- def derived_copy(self, **updates: Unpack[ItemUpdateOptions]) -> Item:
835
+ def derived_copy(
836
+ self,
837
+ action_context: ActionContext | None = None,
838
+ output_num: int = 0,
839
+ **updates: Unpack[ItemUpdateOptions],
840
+ ) -> Item:
799
841
  """
800
842
  Copy item with the given field updates. Resets `store_path` and `source` to None
801
843
  since those should be set explicitly later. Preserves other fields, including
802
- the body.
844
+ the type and the body.
803
845
 
804
846
  Same as `new_copy_with` but also updates the `derived_from` relation. If we also
805
- have an action context, then use the `title_template` to derive a new title.
847
+ have an action context, then use that to fill some fields, in particular `title_template`
848
+ to derive a new title and `output_type` and `output_format` to set the output type
849
+ and format
806
850
  """
851
+
852
+ # Get derived_from relation if possible.
807
853
  if not self.store_path:
808
854
  if self.relations.derived_from:
809
855
  log.message(
@@ -830,30 +876,58 @@ class Item:
830
876
  assert updates["format"] is not None
831
877
  updates["file_ext"] = updates["format"].file_ext
832
878
 
833
- # External resource paths only make sense for resources, so clear them out if new item
834
- # is not a resource.
835
- new_type = updates.get("type") or self.type
836
- if "external_path" not in updates and new_type != ItemType.resource:
879
+ # External resource paths should not be preserved.
880
+ if "external_path" not in updates:
837
881
  updates["external_path"] = None
838
882
 
883
+ action_context = action_context or self.context
884
+
885
+ if action_context:
886
+ # Default the output item type and format to the action's declared output_type
887
+ # and format if not explicitly set.
888
+ if "type" not in updates:
889
+ updates["type"] = action_context.action.output_type
890
+ # If we were not given a format override, we leave the output type the same.
891
+ elif action_context.action.output_format:
892
+ # Check an overridden format and then our own format.
893
+ new_output_format = updates.get("format", self.format)
894
+ if new_output_format and action_context.action.output_format != new_output_format:
895
+ log.warning(
896
+ "Output item format `%s` does not match declared output format `%s` for action `%s`",
897
+ new_output_format,
898
+ action_context.action.output_format,
899
+ action_context.action.name,
900
+ )
901
+
839
902
  new_item = self.new_copy_with(update_timestamp=True, **updates)
840
903
  if derived_from:
841
904
  new_item.update_relations(derived_from=derived_from)
842
905
 
906
+ # Record the history.
907
+ if action_context:
908
+ new_item.update_source(
909
+ Source(
910
+ operation=action_context.operation,
911
+ output_num=output_num,
912
+ cacheable=action_context.action.cacheable,
913
+ )
914
+ )
915
+ action = action_context.action
916
+ else:
917
+ action = None
918
+
843
919
  # Fall back to action title template if we have it and title wasn't explicitly set.
844
920
  if "title" not in updates:
845
921
  prev_title = self.title or (Path(self.store_path).stem if self.store_path else UNTITLED)
846
- if self.context:
847
- action = self.context.action
848
- new_item.title = action.title_template.format(
849
- title=prev_title, action_name=action.name
850
- )
922
+
923
+ if action:
924
+ new_item.title = action.format_title(prev_title)
851
925
  else:
852
- log.warning(
926
+ log.info(
853
927
  "Deriving an item without action context so keeping previous title: %s",
854
928
  self,
855
929
  )
856
- new_item.title = f"{prev_title} (derived copy)"
930
+ new_item.title = prev_title
857
931
 
858
932
  return new_item
859
933
 
@@ -866,9 +940,10 @@ class Item:
866
940
  setattr(self.relations, key, list(value))
867
941
  return self.relations
868
942
 
869
- def update_history(self, source: Source) -> None:
943
+ def update_source(self, source: Source) -> None:
870
944
  """
871
- Update the history of the item with the given operation.
945
+ Update the source and the history of the item to indicate it was created
946
+ by the given operation. For convenience, this is idempotent.
872
947
  """
873
948
  self.source = source
874
949
  self.add_to_history(source.operation.summary())
@@ -900,12 +975,26 @@ class Item:
900
975
  return metadata_matches and body_matches
901
976
 
902
977
  def add_to_history(self, operation_summary: OperationSummary):
978
+ """
979
+ For convenience, this is idempotent.
980
+ """
903
981
  if not self.history:
904
982
  self.history = []
905
983
  # Don't add duplicates to the history.
906
984
  if not self.history or self.history[-1] != operation_summary:
907
985
  self.history.append(operation_summary)
908
986
 
987
+ def mark_as_saved(self, external_path: Path) -> None:
988
+ """
989
+ Mark the item as saved at an external or internal path. If this item is saved to a
990
+ workspace and the path is inside the workspace, the save will be short-circuited.
991
+ If it's outside the workspace, the item will be copied to the workspace.
992
+ Having this method makes it quick to catch bugs where the file is missing.
993
+ """
994
+ if not external_path.exists():
995
+ raise FileNotFoundError(f"Provided path not found: {fmt_loc(external_path)}")
996
+ self.external_path = str(external_path)
997
+
909
998
  def fmt_loc(self) -> str:
910
999
  """
911
1000
  Formatted store path, external path, URL, or title. Use for logging etc.
@@ -949,6 +1038,7 @@ class Item:
949
1038
  key_filter={
950
1039
  "store_path": 0,
951
1040
  "external_path": 64,
1041
+ "original_filename": 64,
952
1042
  "type": 64,
953
1043
  "format": 64,
954
1044
  "state": 64,
@@ -206,10 +206,10 @@ A list of parameter declarations, possibly with default values.
206
206
 
207
207
  # These are the default models for typical use cases.
208
208
  # The user may override them with parameters.
209
- DEFAULT_CAREFUL_LLM = LLM.o3
210
- DEFAULT_STRUCTURED_LLM = LLM.gpt_4o
211
- DEFAULT_STANDARD_LLM = LLM.claude_4_sonnet
212
- DEFAULT_FAST_LLM = LLM.gpt_4o
209
+ DEFAULT_CAREFUL_LLM = LLM.gpt_5
210
+ DEFAULT_STRUCTURED_LLM = LLM.gpt_5
211
+ DEFAULT_STANDARD_LLM = LLM.gpt_5
212
+ DEFAULT_FAST_LLM = LLM.gpt_5_mini
213
213
 
214
214
 
215
215
  # Parameters set globally such as in the workspace.
@@ -10,7 +10,6 @@ from enum import Enum, auto
10
10
  import rich
11
11
  import rich.style
12
12
  from flowmark import Wrap, fill_text
13
- from flowmark.text_filling import DEFAULT_INDENT
14
13
  from rich.console import Group, OverflowMethod, RenderableType
15
14
  from rich.rule import Rule
16
15
  from rich.style import Style
@@ -50,7 +49,7 @@ def print_style(pad_style: PadStyle):
50
49
  Context manager for print styles.
51
50
  """
52
51
  if pad_style == PadStyle.INDENT:
53
- token = print_context_var.set(DEFAULT_INDENT)
52
+ token = print_context_var.set(" ")
54
53
  try:
55
54
  yield
56
55
  finally:
@@ -542,6 +542,8 @@ async def gather_limited_sync(
542
542
  # Mark as failed
543
543
  if status and task_id is not None:
544
544
  await status.finish(task_id, TaskState.FAILED, str(e))
545
+
546
+ log.warning("Task failed: %s: %s", label, e, exc_info=True)
545
547
  raise
546
548
 
547
549
  return await _gather_with_interrupt_handling(
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Iterable, Sequence
4
+ from typing import Any, TypeVar
5
+
6
+ from kash.config.logger import get_logger
7
+ from kash.config.settings import global_settings
8
+ from kash.shell.output.shell_output import multitask_status
9
+ from kash.utils.api_utils.api_retries import RetrySettings
10
+ from kash.utils.api_utils.gather_limited import FuncTask, Limit, gather_limited_sync
11
+
12
+ T = TypeVar("T")
13
+
14
+ log = get_logger(name=__name__)
15
+
16
+
17
+ def _default_labeler(total: int) -> Callable[[int, Any], str]:
18
+ def labeler(i: int, _spec: Any) -> str: # pyright: ignore[reportUnusedParameter]
19
+ return f"Task {i + 1}/{total}"
20
+
21
+ return labeler
22
+
23
+
24
+ async def multitask_gather(
25
+ tasks: Iterable[FuncTask[T]] | Sequence[FuncTask[T]],
26
+ *,
27
+ labeler: Callable[[int, Any], str] | None = None,
28
+ limit: Limit | None = None,
29
+ bucket_limits: dict[str, Limit] | None = None,
30
+ retry_settings: RetrySettings | None = None,
31
+ show_progress: bool = True,
32
+ ) -> list[T]:
33
+ """
34
+ Run many `FuncTask`s concurrently with shared progress UI and rate limits.
35
+
36
+ This wraps the standard pattern of creating a status context, providing a labeler,
37
+ and calling `gather_limited_sync` with common options.
38
+
39
+ - `labeler` can be omitted; a simple "Task X/Y" label will be used.
40
+ - If `limit` is not provided, defaults are taken from `global_settings()`.
41
+ - If `show_progress` is False, tasks are run without the status context.
42
+ - By default, exceptions are returned as results rather than raised (return_exceptions=True).
43
+ """
44
+
45
+ # Normalize tasks to a list for length and stable iteration
46
+ task_list: list[FuncTask[T]] = list(tasks)
47
+
48
+ # Provide a default labeler if none is supplied
49
+ effective_labeler: Callable[[int, Any], str] = (
50
+ labeler if labeler is not None else _default_labeler(len(task_list))
51
+ )
52
+
53
+ # Provide sensible default rate limits if none are supplied
54
+ effective_limit: Limit = (
55
+ limit
56
+ if limit is not None
57
+ else Limit(
58
+ rps=global_settings().limit_rps,
59
+ concurrency=global_settings().limit_concurrency,
60
+ )
61
+ )
62
+
63
+ if not show_progress:
64
+ log.warning("Running %d tasks (progress disabled)…", len(task_list))
65
+
66
+ async with multitask_status(enabled=show_progress) as status:
67
+ return await gather_limited_sync(
68
+ *task_list,
69
+ limit=effective_limit,
70
+ bucket_limits=bucket_limits,
71
+ status=status,
72
+ labeler=effective_labeler,
73
+ retry_settings=retry_settings,
74
+ )