mini-code-cli 0.1.1.0__py3-none-any.whl → 0.1.1.1__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.
@@ -18,9 +18,9 @@ smaller, focused modules so each concern is easy to read and test:
18
18
  * :mod:`mini_code_cli.subagents` -- delegating a task to a sub-agent.
19
19
  * :mod:`mini_code_cli.skills` -- the ``.mini`` skills feature.
20
20
 
21
- For backward compatibility (and the test-suite) the most commonly imported
22
- helpers are re-exported from here, so ``from mini_code_cli.agent_loop import ...``
23
- keeps working.
21
+ The helpers the test-suite imports from here are re-exported through
22
+ ``__all__``, so ``from mini_code_cli.agent_loop import save_history, ...`` works
23
+ without the tests reaching into every module.
24
24
  """
25
25
 
26
26
  import os
@@ -50,11 +50,12 @@ from .persistence import (
50
50
  save_summary,
51
51
  save_usage_segment,
52
52
  save_total_usage,
53
- load_history,
54
- load_history_from_dir,
53
+ load_history_with_index,
55
54
  load_summary_from_dir,
56
55
  load_usage_segment,
57
56
  load_total_usage_from_dir,
57
+ delete_segments_above,
58
+ history_segment_indices,
58
59
  empty_usage,
59
60
  find_last_session_dir,
60
61
  save_last_session_pointer,
@@ -72,7 +73,7 @@ from .runner import call_function_with_timeout
72
73
 
73
74
 
74
75
  __all__ = [
75
- # re-exported helpers (kept for the test-suite / external callers)
76
+ # re-exported helpers (imported from here by the test-suite)
76
77
  "is_within_allowed_dir",
77
78
  "tool_paths_are_allowed",
78
79
  "_FILE_TOOL_PATH_ARGS",
@@ -84,7 +85,6 @@ __all__ = [
84
85
  "call_function_with_timeout",
85
86
  "save_history",
86
87
  "save_summary",
87
- "load_history",
88
88
  "generate_unique_id",
89
89
  "find_last_session_dir",
90
90
  # public classes / entry points
@@ -94,6 +94,25 @@ __all__ = [
94
94
  ]
95
95
 
96
96
 
97
+ # ---------------------------------------------------------------------------
98
+ # recorder helpers (shared by the class and the tests)
99
+ # ---------------------------------------------------------------------------
100
+
101
+ def _safe_int(value, default=0):
102
+ """Coerce ``value`` to ``int``, returning ``default`` when that is impossible.
103
+
104
+ Used by the *display / navigation* paths, which read a recorder that was
105
+ already validated at restore time but may have been hand-edited since: a
106
+ ``/history`` listing must render a junk field rather than crash the CLI.
107
+ A restore itself never comes through here -- it validates strictly instead
108
+ (see ``AgentSession._validate_records``).
109
+ """
110
+ try:
111
+ return int(value)
112
+ except (TypeError, ValueError):
113
+ return default
114
+
115
+
97
116
  # ---------------------------------------------------------------------------
98
117
  # argument parsing
99
118
  # ---------------------------------------------------------------------------
@@ -109,7 +128,7 @@ def parse_args():
109
128
 
110
129
  # Other settings:
111
130
  parser.add_argument("--cache-dir", type=str, help=f"The cache dir for the session histories. default={DEFAULT_CACHE_LOCATION}")
112
- parser.add_argument("--restore-session", type=str, help="The location of a previous session to continue. (e.g. ~/.cache/mini/sessions/1792028090823027-a9d77653/)")
131
+ parser.add_argument("--restore-session", type=str, help="A previous session to continue: its directory (e.g. ~/.cache/mini/sessions/1792028090823027-a9d77653/) or a bare session id under --cache-dir. A session folder must hold a summary_history.json recorder plus the history_{n}.json segment it names; anything else starts a fresh session.")
113
132
  parser.add_argument("-l","--load-last-session", action="store_true", help="Continue the most recent previous session recorded in the --allowed-dir .mini pointer, in place and without the resume prompt, instead of starting fresh. --restore-session takes precedence if also given.")
114
133
 
115
134
  # Agent behaviour related
@@ -169,8 +188,10 @@ def print_help(args):
169
188
  for command, description in (
170
189
  ("/history", "list the conversation (turn number, what happened, where)"),
171
190
  ("/history <n>", "show the full messages recorded for turn n"),
191
+ ("/history last", "...for the most recent turn"),
172
192
  ("/plan", "show the current goal + plan (plan mode only)"),
173
- ("/reset [<n>]", "rewind to the end of turn n (drops everything after it)"),
193
+ ("/reset [<n>|last]", "rewind to the end of turn n (drops everything after it);"),
194
+ ("", "a bare /reset only re-saves the current state (no data loss)"),
174
195
  ("/clear", "clear the screen (keeps the conversation history)"),
175
196
  ("/help", "show this help"),
176
197
  ("/quit, /exit", "save the session and exit"),
@@ -447,6 +468,12 @@ class AgentSession:
447
468
  pointer nor offers the interactive resume prompt, so it always begins a
448
469
  fresh isolated session. That is what makes delegation safe to run
449
470
  unattended -- there is a single place that decides this.
471
+
472
+ All three ways in (``--restore-session``, ``--load-last-session`` and the
473
+ interactive offer) go through :meth:`_try_restore`, which reports *why* a
474
+ restore was refused and then leaves the session clean for
475
+ :meth:`start_new_session`. A refused restore therefore never silently
476
+ reuses the old folder.
450
477
  """
451
478
  if self.background_mode and not self.restore_session:
452
479
  self.start_new_session()
@@ -469,13 +496,24 @@ class AgentSession:
469
496
  def _try_restore(self, restore_directory):
470
497
  """Restore a session in place, returning whether it worked.
471
498
 
472
- A restore failure is not fatal: the caller falls back to a fresh session,
473
- so a corrupt or half-written folder never blocks the CLI from starting.
499
+ A failed restore is not fatal: the caller falls back to a fresh session,
500
+ so a folder this version cannot read (or a typo'd path) never blocks the
501
+ CLI from starting. It is not silent either -- the reason is noted by
502
+ :meth:`restore_previous_session` and the fallback is announced here,
503
+ because a user who asked to continue a conversation needs to know that
504
+ they are not looking at it.
474
505
  """
475
506
  try:
476
- return bool(self.restore_previous_session(restore_directory))
477
- except Exception:
478
- return False
507
+ if self.restore_previous_session(restore_directory):
508
+ return True
509
+ except Exception as e:
510
+ self.console.note(
511
+ f"Could not restore {restore_directory}: {e}", "error")
512
+ self._fail_restore()
513
+ self.console.note(
514
+ f"Starting a fresh session instead of restoring {restore_directory}.",
515
+ "warn")
516
+ return False
479
517
 
480
518
  def _resume_previous(self, detected):
481
519
  """Continue the detected session if the user wants to; True if restored.
@@ -505,6 +543,13 @@ class AgentSession:
505
543
  The id is normally minted in ``__init__`` (see there) and simply reused
506
544
  here; it is re-minted only when it is missing, which is what happens
507
545
  after a failed restore has cleared it.
546
+
547
+ The complete on-disk set is written straight away -- the segment, the
548
+ recorder and the (all-zero) usage files -- so a session this version
549
+ creates always satisfies the restore contract even if the process dies
550
+ before the first quit. A restorable session needs the recorder and the
551
+ segment it names; the usage files are optional, and writing them here
552
+ keeps the folder self-describing rather than half-populated.
508
553
  """
509
554
  self.unique_id = self.unique_id or generate_unique_id()
510
555
  self.session_dir = os.path.join(self.cache_dir, self.unique_id)
@@ -519,6 +564,9 @@ class AgentSession:
519
564
 
520
565
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
521
566
  save_summary(self.summary, self.cache_dir, self.unique_id)
567
+ save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
568
+ self.history_index)
569
+ save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
522
570
 
523
571
  # Record this session in --allowed-dir/.mini so a later
524
572
  # --load-last-session (or the interactive resume offer) can find it.
@@ -526,51 +574,106 @@ class AgentSession:
526
574
 
527
575
 
528
576
  def restore_previous_session(self, restore_directory):
529
- """A function to restore from a directory.
530
-
531
- Order matters here. ``self.messages`` must be loaded *before* the
532
- recorder is restored, because the legacy fallback in
533
- :meth:`_load_summary_if_restored` rebuilds the recorder by scanning the
534
- restored messages. ``self.summary`` / ``self.history_index`` are seeded
535
- first so that fallback can append to them and so ``_persist()`` has a
536
- segment index to write to. Previously all three were unset on this path,
537
- so restoring a session folder that had a ``history_N.json`` but no
538
- ``summary_history.json`` (older code, or a hand-made folder) raised
539
- AttributeError and the caller silently started a *new* session instead.
577
+ """Restore a session from ``restore_directory``, strictly, or refuse it.
578
+
579
+ A restorable folder is exactly what this version writes: a
580
+ ``summary_history.json`` recorder whose records name where each turn
581
+ lives, plus a readable ``history_{n}.json`` for the segment the *last*
582
+ record points at. Nothing else is accepted, and each refusal says which
583
+ part was wrong:
584
+
585
+ * the directory does not exist -> refuse;
586
+ * no recorder (or not a JSON list) -> refuse;
587
+ * a recorder that does not validate -> refuse (``_validate_records``);
588
+ * the named segment missing/empty/garbled -> refuse.
589
+
590
+ There is deliberately no "rebuild the recorder from the transcript" path
591
+ and no single-file ``history.json`` fallback: both had to *invent* turn
592
+ boundaries, which made a rebuilt session behave differently under
593
+ ``/reset`` than a recorded one -- and only sessions this version wrote
594
+ are being restored.
595
+
596
+ Order matters, and it is recorder-first. The recorder says which segment
597
+ the user was really looking at, and the messages are loaded from *that*
598
+ one, so a ``/reset`` that rewound onto an older segment is not undone by
599
+ a stale higher segment left on disk. The loaded index becomes
600
+ ``history_index`` -- the segment later saves append to -- and only then
601
+ are the usage totals restored (they are looked up by that index).
602
+
603
+ Failure is atomic: :meth:`_fail_restore` clears everything a restore
604
+ touches, so the caller's fallback is a clean fresh session that never
605
+ half-adopts this folder or reuses its id.
540
606
  """
607
+ prefix = f"Cannot restore {restore_directory}"
608
+ if not restore_directory or not os.path.isdir(restore_directory):
609
+ self.console.note(f"{prefix}: no such session directory.", "error")
610
+ return self._fail_restore()
611
+
612
+ saved_summary = load_summary_from_dir(restore_directory)
613
+ if saved_summary is None:
614
+ self.console.note(
615
+ f"{prefix}: it has no readable summary_history.json "
616
+ "(the recorder every restorable session has).", "error")
617
+ return self._fail_restore()
618
+ try:
619
+ records = self._validate_records(saved_summary)
620
+ except ValueError as e:
621
+ self.console.note(f"{prefix}: {e}.", "error")
622
+ return self._fail_restore()
623
+
624
+ # A recorder with no turns is a session that quit before finishing one:
625
+ # it is restorable, on the segment it started on.
626
+ preferred = records[-1]["history_file"] if records else 0
627
+ restored, loaded_index = load_history_with_index(restore_directory, preferred)
628
+ if restored is None:
629
+ self.console.note(
630
+ f"{prefix}: history_{preferred}.json is missing, empty or "
631
+ "unreadable.", "error")
632
+ return self._fail_restore()
633
+
541
634
  try:
542
635
  self.unique_id = os.path.basename(os.path.normpath(restore_directory))
543
636
  self.session_dir = restore_directory
544
637
  self.plan_manager = plan_utils.PlanManager(
545
638
  self.session_dir, enabled=self.plan_mode)
546
- self.summary = []
547
- self.history_index = 0
548
- restored = load_history_from_dir(restore_directory) if restore_directory else None
549
-
550
- self.messages = restored if restored is not None else self._build_initial_messages()
639
+ self.messages = restored
640
+ self.summary = records
641
+ self.history_index = int(loaded_index)
551
642
  # No prompt has been sent in *this* process yet, so any remembered
552
643
  # size would be stale: start from a plain estimate of the restored
553
644
  # transcript (which is what project_input_tokens falls back to).
554
645
  self.last_prompt_tokens = 0
555
646
  self.last_prompt_message_count = 0
556
- # The recorder restore decides which segment we are on, so usage --
557
- # which is stored per segment -- has to be restored after it.
558
- self._load_summary_if_restored(restore_directory)
559
647
  self._restore_usage_if_available(restore_directory)
560
648
 
561
649
  # Continuing a session in place makes it the latest one again: keep
562
650
  # the pointer aimed at it so a later --load-last-session finds the
563
651
  # conversation the user is actually working in.
564
652
  self._save_last_session_pointer()
565
-
566
653
  return True
567
-
568
654
  except Exception as e:
569
- self.unique_id = None
570
- self.session_dir = None
571
- self.plan_manager = None
572
- return False
655
+ self.console.note(f"{prefix}: {e}", "error")
656
+ return self._fail_restore()
573
657
 
658
+ def _fail_restore(self):
659
+ """Clear every field a restore may have touched; always returns ``False``.
660
+
661
+ A restore is all-or-nothing. Dropping ``unique_id`` is what guarantees
662
+ the fallback really is a *fresh* session: ``start_new_session`` re-mints
663
+ the id when it is missing, so a refused restore can never append to (or
664
+ overwrite) the folder it just declined to adopt.
665
+ """
666
+ self.unique_id = None
667
+ self.session_dir = None
668
+ self.plan_manager = None
669
+ self.messages = []
670
+ self.summary = []
671
+ self.history_index = 0
672
+ self.token_stats = empty_usage()
673
+ self.total_usage = empty_usage()
674
+ self.last_prompt_tokens = 0
675
+ self.last_prompt_message_count = 0
676
+ return False
574
677
 
575
678
  def _resolve_model(self, args):
576
679
  """Pick the model id: the one the user named, else the server's default.
@@ -667,10 +770,12 @@ class AgentSession:
667
770
 
668
771
  ``token_stats`` comes from the segment we restored onto
669
772
  (``usage_{history_index}.json``) and ``total_usage`` from
670
- ``total_usage.json`` (falling back to the sum of all segments), so the
671
- running summary stays continuous across a restore *and* across the
672
- compaction that may have happened before it. Best-effort: any problem
673
- leaves the totals at zero rather than breaking the restore.
773
+ ``total_usage.json`` (reconstructed from the segment files when it is
774
+ missing), so the running totals stay continuous across a restore *and*
775
+ across the compaction that may have happened before it. Usage is
776
+ optional -- a folder that has no usage files at all still restores, with
777
+ zeros -- and any problem there leaves the totals at zero rather than
778
+ failing the restore.
674
779
  """
675
780
  segment = load_usage_segment(restore_directory, self.history_index)
676
781
  total = load_total_usage_from_dir(restore_directory)
@@ -770,29 +875,52 @@ class AgentSession:
770
875
  if delta > 0:
771
876
  self.total_usage[key] = self.total_usage.get(key, 0) + delta
772
877
 
773
- def _load_summary_if_restored(self, restore_directory):
774
- """Restore the high-level recorder and segment index for a restore.
775
-
776
- Prefers the saved ``summary_history.json`` so the recorder stays flat and
777
- contiguous across the restore, seeding ``self.summary`` and the next
778
- ``history_index`` from its last record. If no recorder was saved (e.g. a
779
- session created by older code, or only a classic ``history.json``), we
780
- rebuild a minimal recorder by scanning the restored messages for
781
- assistant turns, so navigation still works. Best-effort only.
878
+ @staticmethod
879
+ def _validate_records(saved):
880
+ """Validate a loaded recorder strictly; return a clean copy of its records.
881
+
882
+ A ``summary_history.json`` is the *contract* the navigation layer runs
883
+ on: ``/history <n>`` and ``/reset <n>`` index into the list positionally
884
+ and every record says exactly where its turn lives on disk
885
+ (``history_{n}.json`` at message ``index``). A malformed record
886
+ therefore cannot be repaired without inventing a location, so this
887
+ raises ``ValueError`` naming the offending record instead of normalising
888
+ it -- the restore is then refused and the user starts a fresh session,
889
+ which is honest. (The old lenient pass silently dropped junk entries,
890
+ clamped negatives at 0 and renumbered turns ``1..N``; one dropped record
891
+ shifted every later turn number and made a rewind land in the wrong
892
+ place while still reporting success.)
893
+
894
+ Requirements: a JSON list of objects, each with integer ``turn`` /
895
+ ``history_file`` / ``index``, no negative ``history_file`` or ``index``,
896
+ and ``turn == position + 1`` (turns run contiguously from 1). An empty
897
+ list is valid -- a session that quit before finishing a turn.
782
898
  """
783
- saved = load_summary_from_dir(restore_directory)
784
-
785
- saved = [r for r in saved if isinstance(r, dict)]
786
- if saved:
787
- self.summary = saved
788
- self.history_index = int(saved[-1].get("history_file", 0))
789
- return
790
- turn = 0
791
- for i, m in enumerate(self.messages):
792
- if m.get("role") == "assistant":
793
- turn += 1
794
- self.summary.append({"turn": turn, "what": "assistant",
795
- "history_file": 0, "index": i})
899
+ if not isinstance(saved, list):
900
+ raise ValueError("summary_history.json does not hold a list of turns")
901
+ records = []
902
+ for position, entry in enumerate(saved, start=1):
903
+ if not isinstance(entry, dict):
904
+ raise ValueError(f"record {position} is not a JSON object")
905
+ for key in ("turn", "history_file", "index"):
906
+ value = entry.get(key)
907
+ if not isinstance(value, int) or isinstance(value, bool):
908
+ raise ValueError(
909
+ f"record {position} has a non-integer {key} ({value!r})")
910
+ if entry["turn"] != position:
911
+ raise ValueError(
912
+ f"record {position} is numbered turn {entry['turn']} "
913
+ f"(turns must run 1..{len(saved)})")
914
+ if entry["history_file"] < 0 or entry["index"] < 0:
915
+ raise ValueError(
916
+ f"record {position} has a negative history_file/index")
917
+ records.append({
918
+ "turn": entry["turn"],
919
+ "what": str(entry.get("what") or "?"),
920
+ "history_file": entry["history_file"],
921
+ "index": entry["index"],
922
+ })
923
+ return records
796
924
 
797
925
  def _record_turn(self, what, messages, history_index=None):
798
926
  """Append one entry to the high-level recorder.
@@ -815,64 +943,193 @@ class AgentSession:
815
943
 
816
944
  def _show_history(self):
817
945
  """Print the high-level recorder as a numbered list of turns."""
818
- rows = [(str(r.get("turn", i + 1)),
819
- r.get("what", "?"),
820
- f"history_{r.get('history_file', 0)}.json#{r.get('index', 0)}")
821
- for i, r in enumerate(self.summary)]
946
+ if not self.summary:
947
+ self.console.note("No history has been recorded yet.", "warn")
948
+ return
949
+ rows = [(str(_safe_int(r.get("turn", i + 1), i + 1)),
950
+ str(r.get("what") or "?"),
951
+ f"history_{_safe_int(r.get('history_file'), 0)}.json"
952
+ f"#{_safe_int(r.get('index'), 0)}")
953
+ for i, r in enumerate(self.summary) if isinstance(r, dict)]
822
954
  self.console.history_list(rows)
823
955
 
956
+ def _resolve_turn_record(self, arg):
957
+ """Resolve a turn selector to ``(position, record)``, or ``None``.
958
+
959
+ One rule for both ``/history <n>`` and ``/reset <n>``: accept ``last``
960
+ (case-insensitive) or a **positive** 1-based integer, and reject
961
+ everything else with the same message -- ``0``, negatives, non-integers
962
+ and out-of-range values. Previously ``/history`` computed
963
+ ``summary[int(arg) - 1]`` without a lower bound, so ``/history 0``
964
+ silently showed the *last* turn and ``/history -2`` the third-to-last,
965
+ while ``/reset`` rejected exactly those inputs: the same user mistake got
966
+ two different (and one silently wrong) answers.
967
+ """
968
+ count = len(self.summary)
969
+ if not count:
970
+ self.console.note("No history has been recorded yet.", "warn")
971
+ return None
972
+ text = (arg or "").strip().lower()
973
+ if text in ("last", ""):
974
+ return count - 1, self.summary[-1]
975
+ try:
976
+ position = int(text) - 1
977
+ except (TypeError, ValueError):
978
+ position = -1
979
+ if not 0 <= position < count:
980
+ self.console.note(
981
+ f"No such turn {arg!r} (have 1..{count}, or 'last').", "error")
982
+ return None
983
+ return position, self.summary[position]
984
+
824
985
  def _inspect_history(self, arg):
825
986
  """Print the full messages recorded for a single turn."""
826
- try:
827
- record = self.summary[-1] if arg == "last" else self.summary[int(arg) - 1]
828
- except (ValueError, IndexError):
829
- self.console.note(f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
987
+ resolved = self._resolve_turn_record(arg)
988
+ if resolved is None:
830
989
  return
990
+ _, record = resolved
831
991
  messages = self._segment_upto(record)
832
992
  if messages is None:
833
993
  return
834
994
  self.console.history_detail(record, messages, total=len(self.summary))
835
995
 
996
+ def _load_segment(self, history_index):
997
+ """Load ``history_{history_index}.json`` as a message list, or ``None``.
998
+
999
+ Never raises and never trusts the argument: an out-of-range, negative or
1000
+ non-integer index, a missing file, unreadable JSON or a payload that is
1001
+ not a non-empty list all yield ``None``. Navigation and the rewind only
1002
+ ever *read* through here, so a damaged segment can never crash the CLI.
1003
+ """
1004
+ index = _safe_int(history_index, -1)
1005
+ if index < 0 or not self.session_dir:
1006
+ return None
1007
+ path = os.path.join(self.session_dir, f"history_{index}.json")
1008
+ try:
1009
+ with open(path, "r", encoding="utf-8") as f:
1010
+ segment = json.load(f)
1011
+ except Exception:
1012
+ return None
1013
+ if not isinstance(segment, list) or not segment:
1014
+ return None
1015
+ return [m for m in segment if isinstance(m, dict)]
1016
+
1017
+ def _segment_for(self, history_index):
1018
+ """Return the messages of a segment, preferring the live transcript.
1019
+
1020
+ ``_persist()`` only runs on quit (and at a compaction rollover), so the
1021
+ ``history_{n}.json`` file on disk can lag arbitrarily far behind the
1022
+ running conversation. For the *current* segment the in-memory transcript
1023
+ is therefore the authoritative copy -- reading the file instead used to
1024
+ silently drop everything said since the last save (a ``/reset`` could
1025
+ truncate to a state older than the turn it was asked to keep). Older
1026
+ segments are immutable within their lifetime, so they are read from disk.
1027
+ """
1028
+ index = _safe_int(history_index, -1)
1029
+ if index < 0:
1030
+ return None
1031
+ if index == self.history_index:
1032
+ live = [m for m in self.messages if isinstance(m, dict)]
1033
+ if live:
1034
+ return live
1035
+ return self._load_segment(index)
1036
+
836
1037
  def _segment_upto(self, record):
837
1038
  """Load a recorded turn's segment and truncate it at its index.
838
1039
 
839
1040
  Returns the messages reproducing the conversation state at the end of
840
1041
  that turn, or None if the segment could not be loaded (never raises: a
841
- missing or moved segment must not crash navigation).
1042
+ missing or moved segment must not crash navigation). Both recorded
1043
+ numbers are coerced and the index is clamped into the segment, so a
1044
+ stale or hand-edited recorder entry cannot slice outside it.
842
1045
  """
843
- segment_path = os.path.join(
844
- self.session_dir, f"history_{record.get('history_file', 0)}.json")
845
- try:
846
- with open(segment_path, "r", encoding="utf-8") as f:
847
- segment = json.load(f)
848
- except Exception:
1046
+ segment = self._segment_for(record.get("history_file"))
1047
+ if not segment:
849
1048
  self.console.note("Could not load the history segment for that turn.", "error")
850
1049
  return None
851
- idx = max(0, min(int(record.get("index", 0)), len(segment) - 1))
852
- return segment[:idx + 1]
1050
+ index = max(0, min(_safe_int(record.get("index"), 0), len(segment) - 1))
1051
+ return segment[:index + 1]
853
1052
 
854
- def _reset_history(self, keep, record):
1053
+ def _reset_history(self, keep_count, record, force=True):
855
1054
  """Rewind the conversation to the end of a recorded turn.
856
1055
 
857
- Rebuilds ``self.messages`` from the turn's recorded segment + index,
858
- moves the session back onto that segment (``history_index``) and drops
859
- everything after it -- both the extra messages and the later recorder
860
- entries. New turns then continue from that state. Best-effort only: a
861
- segment that cannot be loaded leaves the session unchanged.
1056
+ ``keep_count`` is how many recorder entries to keep (the resolved
1057
+ position + 1 -- *not* ``record["turn"]``, which is display data and used
1058
+ to be trusted for slicing). The work is ordered so a failure cannot leave
1059
+ the live session remembering a state the disk did not accept:
1060
+
1061
+ 1. load the target segment and compute the truncated transcript;
1062
+ 2. refuse (with a warning) when ``force`` is off and messages after the
1063
+ recorded turn exist -- an unrecorded tail must not be silently
1064
+ destroyed;
1065
+ 3. persist the truncated ``history_{n}.json``, ``summary_history.json``
1066
+ and ``total_usage.json`` *first*, rolling the segment/summary back
1067
+ if either write fails;
1068
+ 4. delete the now-orphaned higher segments (best-effort: a failure is
1069
+ a warning, not a rollback -- the rewind is already on disk);
1070
+ 5. only then mutate the live session.
1071
+
1072
+ Afterwards the session is really on segment ``n``: ``token_stats`` is
1073
+ reloaded from ``usage_{n}.json`` (so the next ``_persist()`` cannot write
1074
+ the later segment's counters over it), the remembered prompt size is
1075
+ dropped (it describes a transcript that no longer exists) and
1076
+ ``total_usage`` is left alone -- a rewind does not un-spend tokens.
862
1077
  """
863
- messages = self._segment_upto(record)
864
- if messages is None:
865
- return
1078
+ target_index = _safe_int(record.get("history_file"), -1)
1079
+ full_segment = self._segment_for(target_index)
1080
+ if full_segment is None:
1081
+ self.console.note(
1082
+ "Could not load the history segment for that turn; "
1083
+ "the session is unchanged.", "error")
1084
+ return False
1085
+
1086
+ index = max(0, min(_safe_int(record.get("index"), 0), len(full_segment) - 1))
1087
+ messages = full_segment[:index + 1]
1088
+ trailing = max(len(full_segment) - len(messages), 0)
1089
+ if not force and trailing:
1090
+ self.console.note(
1091
+ f"{trailing} message(s) after turn {keep_count} were never recorded; "
1092
+ f"refusing to discard them. Use /reset {keep_count} to rewind anyway.",
1093
+ "warn")
1094
+ return False
1095
+
1096
+ new_summary = [r for r in self.summary[:keep_count] if isinstance(r, dict)]
1097
+ try:
1098
+ save_history(messages, self.cache_dir, self.unique_id, target_index)
1099
+ save_summary(new_summary, self.cache_dir, self.unique_id)
1100
+ save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
1101
+ except Exception as e:
1102
+ # Put the untouched segment and recorder back, so the on-disk state
1103
+ # still matches the (unchanged) in-memory session.
1104
+ try:
1105
+ save_history(full_segment, self.cache_dir, self.unique_id, target_index)
1106
+ save_summary(self.summary, self.cache_dir, self.unique_id)
1107
+ except Exception:
1108
+ pass
1109
+ self.console.note(
1110
+ f"Could not persist the rewind ({e}); the session is unchanged.", "error")
1111
+ return False
1112
+
1113
+ removed = delete_segments_above(self.session_dir, target_index)
1114
+
866
1115
  self.messages = messages
867
- self.history_index = int(record.get("history_file", 0))
868
- self.summary = self.summary[:keep]
869
- save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
870
- save_summary(self.summary, self.cache_dir, self.unique_id)
1116
+ self.history_index = target_index
1117
+ self.summary = new_summary
1118
+ self.token_stats = load_usage_segment(self.session_dir, target_index)
1119
+ self.last_prompt_tokens = 0
1120
+ self.last_prompt_message_count = 0
1121
+
871
1122
  self.console.note(
872
- f"Reset to turn {keep}: kept {len(self.messages)} messages "
873
- f"(history_{self.history_index}.json up to index "
874
- f"{int(record.get('index', 0))}).", "ok")
1123
+ f"Reset to turn {keep_count}: kept {len(self.messages)} messages "
1124
+ f"(history_{target_index}.json up to index {index}).", "ok")
1125
+ if removed:
1126
+ # Reported at "ok" level, not "info": info notes are hidden in the
1127
+ # default (non-verbose) view, which is exactly where the user needs to
1128
+ # know that files were deleted.
1129
+ self.console.note(
1130
+ f"Removed {len(removed)} later history/usage segment file(s).", "ok")
875
1131
  self.console.note("Continuing: new turns will append to this state.", "info")
1132
+ return True
876
1133
 
877
1134
  def _warn_sudo(self):
878
1135
  """Print a prominent red warning about --sudo mode.
@@ -1363,8 +1620,10 @@ class AgentSession:
1363
1620
  /history list the high-level recorder (turn number, what
1364
1621
  happened, and which history_{n}.json + index)
1365
1622
  /history <n> show the full messages recorded for turn <n>
1366
- /reset [<n>] rewind the conversation to the end of turn <n>
1367
- (no <n> rewinds to the most recent turn)
1623
+ /history last ...for the most recent turn
1624
+ /reset [<n>|last] rewind the conversation to the end of turn <n>
1625
+ (a bare /reset only re-saves the current state;
1626
+ see _do_reset)
1368
1627
  /plan show the current goal + plan the loop maintains
1369
1628
  (plan mode only)
1370
1629
  /clear clear the screen (keeps the conversation)
@@ -1404,25 +1663,38 @@ class AgentSession:
1404
1663
  def _do_reset(self, arg):
1405
1664
  """Rewind the conversation to the end of a recorded turn.
1406
1665
 
1407
- No argument resets to the most recent turn (a no-op that just replays the
1408
- current state). With <n>, rewinds to that turn, dropping the messages and
1409
- recorder entries after it.
1666
+ ``/reset <n>`` and ``/reset last`` are *explicit* rewinds: they truncate
1667
+ the transcript to the end of that turn and drop everything recorded after
1668
+ it. A bare ``/reset`` is the cautious variant -- it never truncates: when
1669
+ the transcript really ends at the last recorded turn it just reports
1670
+ "Already at turn N", and when unrecorded messages follow that turn it
1671
+ *warns* instead of destroying them, because the user never asked for a
1672
+ specific turn. Forcing the rewind is one explicit command away:
1673
+ ``/reset <n>``.
1410
1674
  """
1411
- if not self.summary:
1412
- self.console.note("No history has been recorded yet.", "warn")
1675
+ resolved = self._resolve_turn_record(arg)
1676
+ if resolved is None:
1413
1677
  return
1414
- target = self.summary[-1]
1415
- if arg:
1416
- try:
1417
- idx = int(arg) - 1
1418
- if not (0 <= idx < len(self.summary)):
1419
- raise ValueError
1420
- target = self.summary[idx]
1421
- except ValueError:
1422
- self.console.note(
1423
- f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
1424
- return
1425
- self._reset_history(target["turn"], target)
1678
+ position, record = resolved
1679
+ explicit = bool((arg or "").strip())
1680
+
1681
+ if not explicit and self._transcript_ends_at(record):
1682
+ self.console.note(
1683
+ f"Already at turn {position + 1} (the last recorded turn); "
1684
+ f"nothing to rewind. Use /reset <n> to rewind to an earlier turn.",
1685
+ "ok")
1686
+ return
1687
+ self._reset_history(position + 1, record, force=explicit)
1688
+
1689
+ def _transcript_ends_at(self, record):
1690
+ """Whether the live transcript is exactly the state recorded for a turn.
1691
+
1692
+ That is true when the session is still on the record's segment and its
1693
+ last message is the one the record points at -- i.e. a bare ``/reset``
1694
+ has nothing left to drop.
1695
+ """
1696
+ return (_safe_int(record.get("history_file"), -1) == self.history_index
1697
+ and len(self.messages) == _safe_int(record.get("index"), -1) + 1)
1426
1698
 
1427
1699
  def _show_plan(self):
1428
1700
  """Show the current plan/goal the loop maintains (``/plan`` command).
@@ -1633,20 +1905,49 @@ class AgentSession:
1633
1905
  badge=self._context_badge())
1634
1906
 
1635
1907
  def _persist(self):
1636
- save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
1637
- save_summary(self.summary, self.cache_dir, self.unique_id)
1638
- usage_path = save_usage_segment(self.token_stats, self.cache_dir,
1639
- self.unique_id, self.history_index)
1640
- total_path = save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
1641
- self.console.note(
1642
- f"Saved usage to: {usage_path} (session total: {total_path})", "info")
1908
+ """Write the current segment, recorder and usage totals to disk.
1909
+
1910
+ Each write is attempted independently and none of them may raise: a full
1911
+ disk, a read-only cache dir or a permission hiccup has to degrade to a
1912
+ warning. Quitting must never crash, and a failed transcript save must not
1913
+ stop the usage totals (or the other way round) from being written.
1914
+ """
1915
+ def attempt(label, write):
1916
+ try:
1917
+ return write()
1918
+ except Exception as e:
1919
+ self.console.note(f"Could not save the {label}: {e}", "error")
1920
+ return None
1921
+
1922
+ attempt("history segment", lambda: save_history(
1923
+ self.messages, self.cache_dir, self.unique_id, self.history_index))
1924
+ attempt("history recorder", lambda: save_summary(
1925
+ self.summary, self.cache_dir, self.unique_id))
1926
+ usage_path = attempt("segment usage", lambda: save_usage_segment(
1927
+ self.token_stats, self.cache_dir, self.unique_id, self.history_index))
1928
+ total_path = attempt("session usage", lambda: save_total_usage(
1929
+ self.total_usage, self.cache_dir, self.unique_id))
1930
+ if usage_path or total_path:
1931
+ self.console.note(
1932
+ f"Saved usage to: {usage_path} (session total: {total_path})", "info")
1643
1933
 
1644
1934
  def shutdown(self):
1645
1935
  """Persist and print a final summary (called on quit / exit)."""
1646
1936
  self._persist()
1937
+ segments = history_segment_indices(self.session_dir)
1938
+ # List what is really on disk. The old message claimed
1939
+ # "history_0..{history_index}.json", which was a lie after a /reset had
1940
+ # removed the higher segments (or moved the session back onto an earlier
1941
+ # one) -- it named files that no longer existed and hid the ones that did.
1942
+ if segments:
1943
+ present = (f"history_{segments[0]}.json"
1944
+ if len(segments) == 1 else
1945
+ f"history_{segments[0]}..{segments[-1]}.json")
1946
+ else:
1947
+ present = f"history_{self.history_index}.json"
1647
1948
  self.console.note(
1648
1949
  f"Saved session to {self.cache_dir}/{self.unique_id}/ "
1649
- f"(history_0..{self.history_index}.json + summary_history.json).", "ok")
1950
+ f"({present} + summary_history.json).", "ok")
1650
1951
  self.console.usage_summary(self.total_usage)
1651
1952
 
1652
1953
  # -- single LLM turn ---------------------------------------------------
@@ -1659,6 +1960,9 @@ class AgentSession:
1659
1960
  # rolls the session over to a fresh history segment (history_index + 1)
1660
1961
  # and returns that bump so the recorder can stay flat across it.
1661
1962
  segment_before = dict(self.token_stats)
1963
+ # The segment as it stands *before* compaction: it has to be flushed to
1964
+ # disk as it is being closed (see below), because nothing else writes it.
1965
+ closing_messages = self.messages
1662
1966
  projected = self._projected_input_tokens()
1663
1967
  self.messages, did_compact, new_index = maybe_compact(
1664
1968
  self.messages, self.server_url, self.model, self.api_key,
@@ -1682,10 +1986,21 @@ class AgentSession:
1682
1986
  # would trigger compaction again immediately.
1683
1987
  self.last_prompt_tokens = 0
1684
1988
  self.last_prompt_message_count = 0
1685
- # Close the old segment's usage file before moving on, so its spend
1686
- # is preserved on disk even though the next segment starts at zero.
1687
- save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
1688
- self.history_index)
1989
+ # Close the old segment's files before moving on: its spend is
1990
+ # preserved for the totals, and its *messages* are flushed so the
1991
+ # recorder keeps pointing at a transcript that really exists on disk.
1992
+ # Nothing else writes a closing segment -- the interactive loop only
1993
+ # persists on quit -- so without this history_{n}.json would be short
1994
+ # by every turn recorded in that segment, and /reset (or a restore)
1995
+ # would rewind to a state the user never saw.
1996
+ try:
1997
+ save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
1998
+ self.history_index)
1999
+ save_history(closing_messages, self.cache_dir, self.unique_id,
2000
+ self.history_index)
2001
+ except Exception as e:
2002
+ self.console.note(
2003
+ f"Could not save the closed history segment: {e}", "warn")
1689
2004
  self.history_index = new_index
1690
2005
  self.token_stats = empty_usage()
1691
2006
  # Snapshot the compacted messages into the new segment so its turns
@@ -1785,6 +2100,13 @@ class AgentSession:
1785
2100
  self.messages)
1786
2101
  elif response_text:
1787
2102
  self._record_turn("assistant", self.messages)
2103
+ else:
2104
+ # A step that produced neither text nor tool calls (a failed call, or
2105
+ # a model that answered with nothing) still appended an assistant
2106
+ # message, so it is recorded as well. The recorder must cover the
2107
+ # *final* message: otherwise a bare /reset sees an unrecorded tail
2108
+ # and refuses to touch a conversation that is in fact settled.
2109
+ self._record_turn("assistant (no response)", self.messages)
1788
2110
 
1789
2111
  return made_tool_calls and self.args.auto_mode
1790
2112
 
@@ -1794,7 +2116,7 @@ class _Abort(Exception):
1794
2116
 
1795
2117
 
1796
2118
  # ---------------------------------------------------------------------------
1797
- # module-level helpers kept for readability / backward compatibility
2119
+ # entry point
1798
2120
  # ---------------------------------------------------------------------------
1799
2121
 
1800
2122
  def main():