mini-code-cli 0.1.1.0__py3-none-any.whl → 0.1.1.2__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"),
@@ -246,6 +267,42 @@ def _compaction_trigger(messages, token_limit, reserved_output_tokens, current_t
246
267
  return False, ""
247
268
 
248
269
 
270
+ def _in_flight_tail(messages):
271
+ """Return the messages of the turn that is still in flight, or ``[]``.
272
+
273
+ Compaction rewrites the transcript, so whatever it drops is gone from the
274
+ conversation *and* from everything saved afterwards. The last turn is not
275
+ finished work, though: it is either the instruction being answered right now
276
+ (the transcript ends with a ``user`` message) or the tool exchange the model
277
+ is waiting on (it ends with a ``tool`` result). Either way it has to be
278
+ carried across verbatim, or the model answers the summary instead of the
279
+ user's request -- and the typed line never reaches the recorder, the new
280
+ segment or a later restore.
281
+
282
+ The suffix starts at the last ``user`` message, which always begins a
283
+ self-contained turn: every ``assistant`` tool call inside it is followed by
284
+ its ``tool`` results, so the slice is a valid transcript on its own.
285
+
286
+ ``[]`` when the transcript already ends with an ``assistant`` reply: that
287
+ turn is answered, and re-sending it would ask the model to answer it twice.
288
+ """
289
+ if not messages:
290
+ return []
291
+ last = messages[-1]
292
+ if not isinstance(last, dict) or last.get("role") == "assistant":
293
+ return []
294
+ for index in range(len(messages) - 1, -1, -1):
295
+ message = messages[index]
296
+ if isinstance(message, dict) and message.get("role") == "user":
297
+ return list(messages[index:])
298
+ return []
299
+
300
+
301
+ def _plural(count, noun, suffix="s"):
302
+ """``"1 message"`` / ``"4 messages"`` -- for messages the user reads."""
303
+ return f"{count} {noun}" + ("" if count == 1 else suffix)
304
+
305
+
249
306
  def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats,
250
307
  history_index=0, token_limit=None, reserved_output_tokens=0,
251
308
  current_tokens=None, plan_hook=None, console=None):
@@ -258,7 +315,9 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
258
315
  against ``token_limit``. Nothing else triggers compaction: in particular the
259
316
  number of messages is not a criterion. Compaction is then done by asking the
260
317
  LLM to summarize the history and replacing the old turns with that summary,
261
- keeping the original system prompt as the first message. The compaction call
318
+ keeping the original system prompt as the first message *and the turn that is
319
+ still in flight* (see :func:`_in_flight_tail`) verbatim after it -- a summary
320
+ of the user's current request is not the request. The compaction call
262
321
  itself is a single, tool-free request, so it cannot trigger further tool
263
322
  calls or recurse into compaction.
264
323
 
@@ -268,10 +327,12 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
268
327
  ``plan_hook`` is an optional callable the caller supplies only when running
269
328
  under plan mode (in practice ``AgentSession._handle_compaction``). After the
270
329
  summary is built the hook re-injects the running goal + plan so the compacted
271
- messages carry them forward. It receives the compacted list and must update it
272
- *in place*, because that list is what gets returned to the caller. Passing it in -- instead of referencing the
273
- owning session -- keeps ``maybe_compact`` a small, module-level, network-free
274
- helper that the test-suite can import and exercise directly.
330
+ messages carry them forward. It receives the compacted list *and* the
331
+ in-flight tail (see below) and must update the compacted list in place,
332
+ because that list is what gets returned to the caller. Passing it in --
333
+ instead of referencing the owning session -- keeps ``maybe_compact`` a
334
+ small, module-level, network-free helper that the test-suite can import and
335
+ exercise directly.
275
336
  """
276
337
  # ``console`` defaults to None so callers may omit it, but every branch below
277
338
  # reports through a console. Falling back to the shared one keeps the
@@ -290,8 +351,20 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
290
351
  # bare messages would crash them.
291
352
  return messages, False, history_index
292
353
 
354
+ # The turn this step is answering -- the instruction just typed, or the tool
355
+ # exchange the model is waiting on -- is carried across the rollover (see
356
+ # :func:`_in_flight_tail`). It is computed *before* the slow summary call so
357
+ # the console can tell the user right away that nothing they just sent is at
358
+ # stake; a compaction note that stays silent about that is exactly what makes
359
+ # the rollover look like it might have eaten the message.
360
+ tail = _in_flight_tail(messages)
361
+ kept_note = (f" The current turn ({_plural(len(tail), 'message')}) is kept "
362
+ f"verbatim." if tail else "")
363
+
293
364
  try:
294
- console.note(f"Compacting conversation history ({reason}) -> summary...", "warn")
365
+ console.note(
366
+ f"Compacting the conversation: {_plural(len(messages), 'message')} "
367
+ f"-> a summary ({reason}).{kept_note}", "warn")
295
368
  summary_prompt = prompts.compaction_prompt(messages)
296
369
 
297
370
  # The summariser sometimes answers with nothing at all. An empty summary
@@ -303,7 +376,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
303
376
  # the over-cap transcript was retried, and paid for, on every step.
304
377
  summary = ""
305
378
  for attempt in (1, 2):
306
- console.start_thinking("compacting")
379
+ console.start_thinking("compacting history")
307
380
  try:
308
381
  reply, _, compact_usage, _ = utils.unpack_llm_result(
309
382
  utils.call_openai_server(
@@ -326,9 +399,14 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
326
399
  console.note("The summariser returned an empty summary; retrying once.", "warn")
327
400
 
328
401
  if not summary:
402
+ # Say plainly what did *not* happen: nothing was dropped and the
403
+ # segment did not move -- only the shrink did, so the next request
404
+ # may still be too large.
329
405
  console.note(
330
406
  f"Could not compact history: the summariser returned an empty "
331
- f"summary twice. Keeping all {len(messages)} messages "
407
+ f"summary twice. Keeping all {_plural(len(messages), 'message')} "
408
+ f"and history_{history_index}.json as they are -- nothing was "
409
+ f"lost, but the next request may still be too large "
332
410
  f"({reason}).", "warn")
333
411
  return messages, False, history_index
334
412
 
@@ -341,22 +419,56 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
341
419
  "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
342
420
  })
343
421
 
422
+ # The turn in flight has to survive the rollover. Compaction runs at the
423
+ # *start* of a step, so ``messages`` normally ends with the user message
424
+ # this step is meant to answer, or with the tool result the model is
425
+ # waiting on. Replacing the transcript with just (system, summary)
426
+ # therefore dropped the user's instruction on the floor: the model
427
+ # answered the summary -- "Understood. What would you like me to do?" --
428
+ # and the typed line never reached the new segment, the recorder or a
429
+ # later restore (it survived only inside the *closed* segment, which no
430
+ # recorder record points at any more). ``tail`` was computed before the
431
+ # summary call so the opening note could already promise this.
432
+
344
433
  # In plan mode, re-inject the running plan + current goal so a
345
434
  # compaction (which would otherwise drop them) keeps the model
346
435
  # working against the goal it is still trying to finish. The hook is
347
436
  # wired in only when the caller runs under plan mode; otherwise
348
- # compaction degrades to a plain summary.
437
+ # compaction degrades to a plain summary. It is also handed the tail so
438
+ # it can restate the *real* instruction rather than the summary text.
349
439
  if plan_hook is not None:
350
- plan_hook(compacted)
351
-
352
- console.note(f"History compacted to {len(compacted)} messages.", "warn")
440
+ plan_hook(compacted, tail)
441
+
442
+ # ... and only then the live turn, so the user's actual instruction
443
+ # reads as the most recent thing the model has to act on.
444
+ compacted.extend(tail)
445
+
446
+ # What the user is shown has to answer the three questions a rollover
447
+ # raises: *what* was folded away (the summary itself), *how much* (the
448
+ # closing segment's message count, and what the new one starts with),
449
+ # and *where it went* (which segment file was closed and which was
450
+ # started).
451
+ console.note(f"Summary preview: {utils.oneline(summary, 120)}", "warn")
452
+ kept = (f"summary + {_plural(len(tail), 'kept message')}" if tail
453
+ else "summary only")
454
+ # Phrased as "old segment -> new segment" rather than "N messages -> M
455
+ # messages": the replacement can be *longer* than what it replaced (a
456
+ # system prompt, the summary and the kept turn), and a bare pair of
457
+ # counts that sometimes grows reads like a bug.
458
+ console.note(
459
+ f"Compacted history_{history_index}.json "
460
+ f"({_plural(len(messages), 'message')}) into a summary; "
461
+ f"history_{history_index + 1}.json now starts with "
462
+ f"{_plural(len(compacted), 'message')} ({kept}).", "warn")
353
463
  # A new segment (history_index + 1) is started on compaction: the
354
464
  # compacted messages are its snapshot, and every later turn appends
355
465
  # to it. The caller records a single high-level 'summary' turn for
356
466
  # this index so the recorder stays flat across the rollover.
357
467
  return compacted, True, history_index + 1
358
468
  except Exception as e:
359
- console.note(f"Failed to compact history: {e}", "error")
469
+ console.note(
470
+ f"Failed to compact history: {e}. Keeping all "
471
+ f"{_plural(len(messages), 'message')} unchanged.", "error")
360
472
 
361
473
  return messages, False, history_index
362
474
 
@@ -406,6 +518,11 @@ class AgentSession:
406
518
 
407
519
  self.plan_mode = bool(getattr(self.args, "plan", False))
408
520
 
521
+ # The good-bye box is printed once, from shutdown(). Several exits
522
+ # funnel through there (quit, exit, EOF, a /quit command) and a repeated
523
+ # box would read like a second session had ended.
524
+ self._farewell_shown = False
525
+
409
526
  # Token accounting. ``token_stats`` covers the *current history
410
527
  # segment* and is reset when compaction rolls over to a new one;
411
528
  # ``total_usage`` is the whole-session accumulator and never decreases.
@@ -447,6 +564,12 @@ class AgentSession:
447
564
  pointer nor offers the interactive resume prompt, so it always begins a
448
565
  fresh isolated session. That is what makes delegation safe to run
449
566
  unattended -- there is a single place that decides this.
567
+
568
+ All three ways in (``--restore-session``, ``--load-last-session`` and the
569
+ interactive offer) go through :meth:`_try_restore`, which reports *why* a
570
+ restore was refused and then leaves the session clean for
571
+ :meth:`start_new_session`. A refused restore therefore never silently
572
+ reuses the old folder.
450
573
  """
451
574
  if self.background_mode and not self.restore_session:
452
575
  self.start_new_session()
@@ -469,13 +592,24 @@ class AgentSession:
469
592
  def _try_restore(self, restore_directory):
470
593
  """Restore a session in place, returning whether it worked.
471
594
 
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.
595
+ A failed restore is not fatal: the caller falls back to a fresh session,
596
+ so a folder this version cannot read (or a typo'd path) never blocks the
597
+ CLI from starting. It is not silent either -- the reason is noted by
598
+ :meth:`restore_previous_session` and the fallback is announced here,
599
+ because a user who asked to continue a conversation needs to know that
600
+ they are not looking at it.
474
601
  """
475
602
  try:
476
- return bool(self.restore_previous_session(restore_directory))
477
- except Exception:
478
- return False
603
+ if self.restore_previous_session(restore_directory):
604
+ return True
605
+ except Exception as e:
606
+ self.console.note(
607
+ f"Could not restore {restore_directory}: {e}", "error")
608
+ self._fail_restore()
609
+ self.console.note(
610
+ f"Starting a fresh session instead of restoring {restore_directory}.",
611
+ "warn")
612
+ return False
479
613
 
480
614
  def _resume_previous(self, detected):
481
615
  """Continue the detected session if the user wants to; True if restored.
@@ -505,6 +639,13 @@ class AgentSession:
505
639
  The id is normally minted in ``__init__`` (see there) and simply reused
506
640
  here; it is re-minted only when it is missing, which is what happens
507
641
  after a failed restore has cleared it.
642
+
643
+ The complete on-disk set is written straight away -- the segment, the
644
+ recorder and the (all-zero) usage files -- so a session this version
645
+ creates always satisfies the restore contract even if the process dies
646
+ before the first quit. A restorable session needs the recorder and the
647
+ segment it names; the usage files are optional, and writing them here
648
+ keeps the folder self-describing rather than half-populated.
508
649
  """
509
650
  self.unique_id = self.unique_id or generate_unique_id()
510
651
  self.session_dir = os.path.join(self.cache_dir, self.unique_id)
@@ -519,6 +660,9 @@ class AgentSession:
519
660
 
520
661
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
521
662
  save_summary(self.summary, self.cache_dir, self.unique_id)
663
+ save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
664
+ self.history_index)
665
+ save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
522
666
 
523
667
  # Record this session in --allowed-dir/.mini so a later
524
668
  # --load-last-session (or the interactive resume offer) can find it.
@@ -526,51 +670,106 @@ class AgentSession:
526
670
 
527
671
 
528
672
  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.
673
+ """Restore a session from ``restore_directory``, strictly, or refuse it.
674
+
675
+ A restorable folder is exactly what this version writes: a
676
+ ``summary_history.json`` recorder whose records name where each turn
677
+ lives, plus a readable ``history_{n}.json`` for the segment the *last*
678
+ record points at. Nothing else is accepted, and each refusal says which
679
+ part was wrong:
680
+
681
+ * the directory does not exist -> refuse;
682
+ * no recorder (or not a JSON list) -> refuse;
683
+ * a recorder that does not validate -> refuse (``_validate_records``);
684
+ * the named segment missing/empty/garbled -> refuse.
685
+
686
+ There is deliberately no "rebuild the recorder from the transcript" path
687
+ and no single-file ``history.json`` fallback: both had to *invent* turn
688
+ boundaries, which made a rebuilt session behave differently under
689
+ ``/reset`` than a recorded one -- and only sessions this version wrote
690
+ are being restored.
691
+
692
+ Order matters, and it is recorder-first. The recorder says which segment
693
+ the user was really looking at, and the messages are loaded from *that*
694
+ one, so a ``/reset`` that rewound onto an older segment is not undone by
695
+ a stale higher segment left on disk. The loaded index becomes
696
+ ``history_index`` -- the segment later saves append to -- and only then
697
+ are the usage totals restored (they are looked up by that index).
698
+
699
+ Failure is atomic: :meth:`_fail_restore` clears everything a restore
700
+ touches, so the caller's fallback is a clean fresh session that never
701
+ half-adopts this folder or reuses its id.
540
702
  """
703
+ prefix = f"Cannot restore {restore_directory}"
704
+ if not restore_directory or not os.path.isdir(restore_directory):
705
+ self.console.note(f"{prefix}: no such session directory.", "error")
706
+ return self._fail_restore()
707
+
708
+ saved_summary = load_summary_from_dir(restore_directory)
709
+ if saved_summary is None:
710
+ self.console.note(
711
+ f"{prefix}: it has no readable summary_history.json "
712
+ "(the recorder every restorable session has).", "error")
713
+ return self._fail_restore()
714
+ try:
715
+ records = self._validate_records(saved_summary)
716
+ except ValueError as e:
717
+ self.console.note(f"{prefix}: {e}.", "error")
718
+ return self._fail_restore()
719
+
720
+ # A recorder with no turns is a session that quit before finishing one:
721
+ # it is restorable, on the segment it started on.
722
+ preferred = records[-1]["history_file"] if records else 0
723
+ restored, loaded_index = load_history_with_index(restore_directory, preferred)
724
+ if restored is None:
725
+ self.console.note(
726
+ f"{prefix}: history_{preferred}.json is missing, empty or "
727
+ "unreadable.", "error")
728
+ return self._fail_restore()
729
+
541
730
  try:
542
731
  self.unique_id = os.path.basename(os.path.normpath(restore_directory))
543
732
  self.session_dir = restore_directory
544
733
  self.plan_manager = plan_utils.PlanManager(
545
734
  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()
735
+ self.messages = restored
736
+ self.summary = records
737
+ self.history_index = int(loaded_index)
551
738
  # No prompt has been sent in *this* process yet, so any remembered
552
739
  # size would be stale: start from a plain estimate of the restored
553
740
  # transcript (which is what project_input_tokens falls back to).
554
741
  self.last_prompt_tokens = 0
555
742
  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
743
  self._restore_usage_if_available(restore_directory)
560
744
 
561
745
  # Continuing a session in place makes it the latest one again: keep
562
746
  # the pointer aimed at it so a later --load-last-session finds the
563
747
  # conversation the user is actually working in.
564
748
  self._save_last_session_pointer()
565
-
566
749
  return True
567
-
568
750
  except Exception as e:
569
- self.unique_id = None
570
- self.session_dir = None
571
- self.plan_manager = None
572
- return False
751
+ self.console.note(f"{prefix}: {e}", "error")
752
+ return self._fail_restore()
573
753
 
754
+ def _fail_restore(self):
755
+ """Clear every field a restore may have touched; always returns ``False``.
756
+
757
+ A restore is all-or-nothing. Dropping ``unique_id`` is what guarantees
758
+ the fallback really is a *fresh* session: ``start_new_session`` re-mints
759
+ the id when it is missing, so a refused restore can never append to (or
760
+ overwrite) the folder it just declined to adopt.
761
+ """
762
+ self.unique_id = None
763
+ self.session_dir = None
764
+ self.plan_manager = None
765
+ self.messages = []
766
+ self.summary = []
767
+ self.history_index = 0
768
+ self.token_stats = empty_usage()
769
+ self.total_usage = empty_usage()
770
+ self.last_prompt_tokens = 0
771
+ self.last_prompt_message_count = 0
772
+ return False
574
773
 
575
774
  def _resolve_model(self, args):
576
775
  """Pick the model id: the one the user named, else the server's default.
@@ -667,10 +866,12 @@ class AgentSession:
667
866
 
668
867
  ``token_stats`` comes from the segment we restored onto
669
868
  (``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.
869
+ ``total_usage.json`` (reconstructed from the segment files when it is
870
+ missing), so the running totals stay continuous across a restore *and*
871
+ across the compaction that may have happened before it. Usage is
872
+ optional -- a folder that has no usage files at all still restores, with
873
+ zeros -- and any problem there leaves the totals at zero rather than
874
+ failing the restore.
674
875
  """
675
876
  segment = load_usage_segment(restore_directory, self.history_index)
676
877
  total = load_total_usage_from_dir(restore_directory)
@@ -770,29 +971,52 @@ class AgentSession:
770
971
  if delta > 0:
771
972
  self.total_usage[key] = self.total_usage.get(key, 0) + delta
772
973
 
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.
974
+ @staticmethod
975
+ def _validate_records(saved):
976
+ """Validate a loaded recorder strictly; return a clean copy of its records.
977
+
978
+ A ``summary_history.json`` is the *contract* the navigation layer runs
979
+ on: ``/history <n>`` and ``/reset <n>`` index into the list positionally
980
+ and every record says exactly where its turn lives on disk
981
+ (``history_{n}.json`` at message ``index``). A malformed record
982
+ therefore cannot be repaired without inventing a location, so this
983
+ raises ``ValueError`` naming the offending record instead of normalising
984
+ it -- the restore is then refused and the user starts a fresh session,
985
+ which is honest. (The old lenient pass silently dropped junk entries,
986
+ clamped negatives at 0 and renumbered turns ``1..N``; one dropped record
987
+ shifted every later turn number and made a rewind land in the wrong
988
+ place while still reporting success.)
989
+
990
+ Requirements: a JSON list of objects, each with integer ``turn`` /
991
+ ``history_file`` / ``index``, no negative ``history_file`` or ``index``,
992
+ and ``turn == position + 1`` (turns run contiguously from 1). An empty
993
+ list is valid -- a session that quit before finishing a turn.
782
994
  """
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})
995
+ if not isinstance(saved, list):
996
+ raise ValueError("summary_history.json does not hold a list of turns")
997
+ records = []
998
+ for position, entry in enumerate(saved, start=1):
999
+ if not isinstance(entry, dict):
1000
+ raise ValueError(f"record {position} is not a JSON object")
1001
+ for key in ("turn", "history_file", "index"):
1002
+ value = entry.get(key)
1003
+ if not isinstance(value, int) or isinstance(value, bool):
1004
+ raise ValueError(
1005
+ f"record {position} has a non-integer {key} ({value!r})")
1006
+ if entry["turn"] != position:
1007
+ raise ValueError(
1008
+ f"record {position} is numbered turn {entry['turn']} "
1009
+ f"(turns must run 1..{len(saved)})")
1010
+ if entry["history_file"] < 0 or entry["index"] < 0:
1011
+ raise ValueError(
1012
+ f"record {position} has a negative history_file/index")
1013
+ records.append({
1014
+ "turn": entry["turn"],
1015
+ "what": str(entry.get("what") or "?"),
1016
+ "history_file": entry["history_file"],
1017
+ "index": entry["index"],
1018
+ })
1019
+ return records
796
1020
 
797
1021
  def _record_turn(self, what, messages, history_index=None):
798
1022
  """Append one entry to the high-level recorder.
@@ -815,64 +1039,193 @@ class AgentSession:
815
1039
 
816
1040
  def _show_history(self):
817
1041
  """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)]
1042
+ if not self.summary:
1043
+ self.console.note("No history has been recorded yet.", "warn")
1044
+ return
1045
+ rows = [(str(_safe_int(r.get("turn", i + 1), i + 1)),
1046
+ str(r.get("what") or "?"),
1047
+ f"history_{_safe_int(r.get('history_file'), 0)}.json"
1048
+ f"#{_safe_int(r.get('index'), 0)}")
1049
+ for i, r in enumerate(self.summary) if isinstance(r, dict)]
822
1050
  self.console.history_list(rows)
823
1051
 
1052
+ def _resolve_turn_record(self, arg):
1053
+ """Resolve a turn selector to ``(position, record)``, or ``None``.
1054
+
1055
+ One rule for both ``/history <n>`` and ``/reset <n>``: accept ``last``
1056
+ (case-insensitive) or a **positive** 1-based integer, and reject
1057
+ everything else with the same message -- ``0``, negatives, non-integers
1058
+ and out-of-range values. Previously ``/history`` computed
1059
+ ``summary[int(arg) - 1]`` without a lower bound, so ``/history 0``
1060
+ silently showed the *last* turn and ``/history -2`` the third-to-last,
1061
+ while ``/reset`` rejected exactly those inputs: the same user mistake got
1062
+ two different (and one silently wrong) answers.
1063
+ """
1064
+ count = len(self.summary)
1065
+ if not count:
1066
+ self.console.note("No history has been recorded yet.", "warn")
1067
+ return None
1068
+ text = (arg or "").strip().lower()
1069
+ if text in ("last", ""):
1070
+ return count - 1, self.summary[-1]
1071
+ try:
1072
+ position = int(text) - 1
1073
+ except (TypeError, ValueError):
1074
+ position = -1
1075
+ if not 0 <= position < count:
1076
+ self.console.note(
1077
+ f"No such turn {arg!r} (have 1..{count}, or 'last').", "error")
1078
+ return None
1079
+ return position, self.summary[position]
1080
+
824
1081
  def _inspect_history(self, arg):
825
1082
  """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")
1083
+ resolved = self._resolve_turn_record(arg)
1084
+ if resolved is None:
830
1085
  return
1086
+ _, record = resolved
831
1087
  messages = self._segment_upto(record)
832
1088
  if messages is None:
833
1089
  return
834
1090
  self.console.history_detail(record, messages, total=len(self.summary))
835
1091
 
1092
+ def _load_segment(self, history_index):
1093
+ """Load ``history_{history_index}.json`` as a message list, or ``None``.
1094
+
1095
+ Never raises and never trusts the argument: an out-of-range, negative or
1096
+ non-integer index, a missing file, unreadable JSON or a payload that is
1097
+ not a non-empty list all yield ``None``. Navigation and the rewind only
1098
+ ever *read* through here, so a damaged segment can never crash the CLI.
1099
+ """
1100
+ index = _safe_int(history_index, -1)
1101
+ if index < 0 or not self.session_dir:
1102
+ return None
1103
+ path = os.path.join(self.session_dir, f"history_{index}.json")
1104
+ try:
1105
+ with open(path, "r", encoding="utf-8") as f:
1106
+ segment = json.load(f)
1107
+ except Exception:
1108
+ return None
1109
+ if not isinstance(segment, list) or not segment:
1110
+ return None
1111
+ return [m for m in segment if isinstance(m, dict)]
1112
+
1113
+ def _segment_for(self, history_index):
1114
+ """Return the messages of a segment, preferring the live transcript.
1115
+
1116
+ ``_persist()`` only runs on quit (and at a compaction rollover), so the
1117
+ ``history_{n}.json`` file on disk can lag arbitrarily far behind the
1118
+ running conversation. For the *current* segment the in-memory transcript
1119
+ is therefore the authoritative copy -- reading the file instead used to
1120
+ silently drop everything said since the last save (a ``/reset`` could
1121
+ truncate to a state older than the turn it was asked to keep). Older
1122
+ segments are immutable within their lifetime, so they are read from disk.
1123
+ """
1124
+ index = _safe_int(history_index, -1)
1125
+ if index < 0:
1126
+ return None
1127
+ if index == self.history_index:
1128
+ live = [m for m in self.messages if isinstance(m, dict)]
1129
+ if live:
1130
+ return live
1131
+ return self._load_segment(index)
1132
+
836
1133
  def _segment_upto(self, record):
837
1134
  """Load a recorded turn's segment and truncate it at its index.
838
1135
 
839
1136
  Returns the messages reproducing the conversation state at the end of
840
1137
  that turn, or None if the segment could not be loaded (never raises: a
841
- missing or moved segment must not crash navigation).
1138
+ missing or moved segment must not crash navigation). Both recorded
1139
+ numbers are coerced and the index is clamped into the segment, so a
1140
+ stale or hand-edited recorder entry cannot slice outside it.
842
1141
  """
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:
1142
+ segment = self._segment_for(record.get("history_file"))
1143
+ if not segment:
849
1144
  self.console.note("Could not load the history segment for that turn.", "error")
850
1145
  return None
851
- idx = max(0, min(int(record.get("index", 0)), len(segment) - 1))
852
- return segment[:idx + 1]
1146
+ index = max(0, min(_safe_int(record.get("index"), 0), len(segment) - 1))
1147
+ return segment[:index + 1]
853
1148
 
854
- def _reset_history(self, keep, record):
1149
+ def _reset_history(self, keep_count, record, force=True):
855
1150
  """Rewind the conversation to the end of a recorded turn.
856
1151
 
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.
1152
+ ``keep_count`` is how many recorder entries to keep (the resolved
1153
+ position + 1 -- *not* ``record["turn"]``, which is display data and used
1154
+ to be trusted for slicing). The work is ordered so a failure cannot leave
1155
+ the live session remembering a state the disk did not accept:
1156
+
1157
+ 1. load the target segment and compute the truncated transcript;
1158
+ 2. refuse (with a warning) when ``force`` is off and messages after the
1159
+ recorded turn exist -- an unrecorded tail must not be silently
1160
+ destroyed;
1161
+ 3. persist the truncated ``history_{n}.json``, ``summary_history.json``
1162
+ and ``total_usage.json`` *first*, rolling the segment/summary back
1163
+ if either write fails;
1164
+ 4. delete the now-orphaned higher segments (best-effort: a failure is
1165
+ a warning, not a rollback -- the rewind is already on disk);
1166
+ 5. only then mutate the live session.
1167
+
1168
+ Afterwards the session is really on segment ``n``: ``token_stats`` is
1169
+ reloaded from ``usage_{n}.json`` (so the next ``_persist()`` cannot write
1170
+ the later segment's counters over it), the remembered prompt size is
1171
+ dropped (it describes a transcript that no longer exists) and
1172
+ ``total_usage`` is left alone -- a rewind does not un-spend tokens.
862
1173
  """
863
- messages = self._segment_upto(record)
864
- if messages is None:
865
- return
1174
+ target_index = _safe_int(record.get("history_file"), -1)
1175
+ full_segment = self._segment_for(target_index)
1176
+ if full_segment is None:
1177
+ self.console.note(
1178
+ "Could not load the history segment for that turn; "
1179
+ "the session is unchanged.", "error")
1180
+ return False
1181
+
1182
+ index = max(0, min(_safe_int(record.get("index"), 0), len(full_segment) - 1))
1183
+ messages = full_segment[:index + 1]
1184
+ trailing = max(len(full_segment) - len(messages), 0)
1185
+ if not force and trailing:
1186
+ self.console.note(
1187
+ f"{trailing} message(s) after turn {keep_count} were never recorded; "
1188
+ f"refusing to discard them. Use /reset {keep_count} to rewind anyway.",
1189
+ "warn")
1190
+ return False
1191
+
1192
+ new_summary = [r for r in self.summary[:keep_count] if isinstance(r, dict)]
1193
+ try:
1194
+ save_history(messages, self.cache_dir, self.unique_id, target_index)
1195
+ save_summary(new_summary, self.cache_dir, self.unique_id)
1196
+ save_total_usage(self.total_usage, self.cache_dir, self.unique_id)
1197
+ except Exception as e:
1198
+ # Put the untouched segment and recorder back, so the on-disk state
1199
+ # still matches the (unchanged) in-memory session.
1200
+ try:
1201
+ save_history(full_segment, self.cache_dir, self.unique_id, target_index)
1202
+ save_summary(self.summary, self.cache_dir, self.unique_id)
1203
+ except Exception:
1204
+ pass
1205
+ self.console.note(
1206
+ f"Could not persist the rewind ({e}); the session is unchanged.", "error")
1207
+ return False
1208
+
1209
+ removed = delete_segments_above(self.session_dir, target_index)
1210
+
866
1211
  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)
1212
+ self.history_index = target_index
1213
+ self.summary = new_summary
1214
+ self.token_stats = load_usage_segment(self.session_dir, target_index)
1215
+ self.last_prompt_tokens = 0
1216
+ self.last_prompt_message_count = 0
1217
+
871
1218
  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")
1219
+ f"Reset to turn {keep_count}: kept {len(self.messages)} messages "
1220
+ f"(history_{target_index}.json up to index {index}).", "ok")
1221
+ if removed:
1222
+ # Reported at "ok" level, not "info": info notes are hidden in the
1223
+ # default (non-verbose) view, which is exactly where the user needs to
1224
+ # know that files were deleted.
1225
+ self.console.note(
1226
+ f"Removed {len(removed)} later history/usage segment file(s).", "ok")
875
1227
  self.console.note("Continuing: new turns will append to this state.", "info")
1228
+ return True
876
1229
 
877
1230
  def _warn_sudo(self):
878
1231
  """Print a prominent red warning about --sudo mode.
@@ -1006,8 +1359,12 @@ class AgentSession:
1006
1359
  return
1007
1360
  except KeyboardInterrupt:
1008
1361
  self.console.note("Interrupting: Mini Code Exiting...", "error")
1362
+ self.shutdown(farewell=False)
1363
+ return
1009
1364
  except Exception as e:
1010
1365
  self.console.note(f"Exception {e}. Exiting...", "error")
1366
+ self.shutdown(farewell=False)
1367
+ return
1011
1368
  self.shutdown()
1012
1369
 
1013
1370
  def _emit_tip(self):
@@ -1251,7 +1608,7 @@ class AgentSession:
1251
1608
  return True
1252
1609
 
1253
1610
 
1254
- def _handle_compaction(self, compacted):
1611
+ def _handle_compaction(self, compacted, tail=()):
1255
1612
  """Re-inject goal + plan into the compacted messages (plan mode).
1256
1613
 
1257
1614
  Plan-mode counterpart to the plan handling inside :meth:`maybe_compact`,
@@ -1259,7 +1616,8 @@ class AgentSession:
1259
1616
  a network-free module-level helper. The loop wires in this method only
1260
1617
  when plan mode is on; it re-injects the goal + plan via
1261
1618
  :meth:`_apply_plan_to_compacted`, which also rewrites the tracking files
1262
- to match.
1619
+ to match. ``tail`` is the turn that is still in flight, which
1620
+ ``maybe_compact`` appends after the hook's messages.
1263
1621
 
1264
1622
  The hook must update ``compacted`` **in place**: ``maybe_compact``
1265
1623
  returns *that* list to the caller, which then assigns it to
@@ -1267,9 +1625,9 @@ class AgentSession:
1267
1625
  discarded a moment later, silently dropping the goal and plan -- which is
1268
1626
  exactly what happened before this was a slice assignment.
1269
1627
  """
1270
- compacted[:] = self._apply_plan_to_compacted(compacted)
1628
+ compacted[:] = self._apply_plan_to_compacted(compacted, tail)
1271
1629
 
1272
- def _apply_plan_to_compacted(self, messages):
1630
+ def _apply_plan_to_compacted(self, messages, tail=()):
1273
1631
  """Re-inject the current goal + plan after a history compaction.
1274
1632
 
1275
1633
  Compaction replaces the transcript with a summary, which would otherwise
@@ -1277,16 +1635,18 @@ class AgentSession:
1277
1635
  back off disk and append them as fresh user turns so the model keeps
1278
1636
  working against the goal it is still trying to finish. The goal is
1279
1637
  appended last so it reads as the most recent instruction the model
1280
- should act on.
1638
+ should act on -- ``maybe_compact`` then appends the in-flight turn, which
1639
+ is the same instruction verbatim.
1281
1640
  """
1282
1641
  plan_text = self.plan_manager.read_plan()
1283
1642
  goal_text = self.plan_manager.read_goal()
1284
1643
 
1285
- # Recover the user's latest instruction from the pre-compaction tail, in
1286
- # case the loop had not recorded a goal yet, so there is always a goal to
1287
- # restate after compaction.
1644
+ # Recover the user's latest instruction from the *in-flight turn* -- not
1645
+ # from ``messages``, which by now holds the summary -- so a session that
1646
+ # compacts before its goal was recorded still restates the real
1647
+ # instruction instead of the summary text.
1288
1648
  current_input = None
1289
- for m in reversed(messages):
1649
+ for m in reversed(list(tail or ())):
1290
1650
  if m.get("role") == "user":
1291
1651
  current_input = m.get("content")
1292
1652
  break
@@ -1363,8 +1723,10 @@ class AgentSession:
1363
1723
  /history list the high-level recorder (turn number, what
1364
1724
  happened, and which history_{n}.json + index)
1365
1725
  /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)
1726
+ /history last ...for the most recent turn
1727
+ /reset [<n>|last] rewind the conversation to the end of turn <n>
1728
+ (a bare /reset only re-saves the current state;
1729
+ see _do_reset)
1368
1730
  /plan show the current goal + plan the loop maintains
1369
1731
  (plan mode only)
1370
1732
  /clear clear the screen (keeps the conversation)
@@ -1404,25 +1766,38 @@ class AgentSession:
1404
1766
  def _do_reset(self, arg):
1405
1767
  """Rewind the conversation to the end of a recorded turn.
1406
1768
 
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.
1769
+ ``/reset <n>`` and ``/reset last`` are *explicit* rewinds: they truncate
1770
+ the transcript to the end of that turn and drop everything recorded after
1771
+ it. A bare ``/reset`` is the cautious variant -- it never truncates: when
1772
+ the transcript really ends at the last recorded turn it just reports
1773
+ "Already at turn N", and when unrecorded messages follow that turn it
1774
+ *warns* instead of destroying them, because the user never asked for a
1775
+ specific turn. Forcing the rewind is one explicit command away:
1776
+ ``/reset <n>``.
1410
1777
  """
1411
- if not self.summary:
1412
- self.console.note("No history has been recorded yet.", "warn")
1778
+ resolved = self._resolve_turn_record(arg)
1779
+ if resolved is None:
1413
1780
  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)
1781
+ position, record = resolved
1782
+ explicit = bool((arg or "").strip())
1783
+
1784
+ if not explicit and self._transcript_ends_at(record):
1785
+ self.console.note(
1786
+ f"Already at turn {position + 1} (the last recorded turn); "
1787
+ f"nothing to rewind. Use /reset <n> to rewind to an earlier turn.",
1788
+ "ok")
1789
+ return
1790
+ self._reset_history(position + 1, record, force=explicit)
1791
+
1792
+ def _transcript_ends_at(self, record):
1793
+ """Whether the live transcript is exactly the state recorded for a turn.
1794
+
1795
+ That is true when the session is still on the record's segment and its
1796
+ last message is the one the record points at -- i.e. a bare ``/reset``
1797
+ has nothing left to drop.
1798
+ """
1799
+ return (_safe_int(record.get("history_file"), -1) == self.history_index
1800
+ and len(self.messages) == _safe_int(record.get("index"), -1) + 1)
1426
1801
 
1427
1802
  def _show_plan(self):
1428
1803
  """Show the current plan/goal the loop maintains (``/plan`` command).
@@ -1633,21 +2008,112 @@ class AgentSession:
1633
2008
  badge=self._context_badge())
1634
2009
 
1635
2010
  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")
2011
+ """Write the current segment, recorder and usage totals to disk.
1643
2012
 
1644
- def shutdown(self):
1645
- """Persist and print a final summary (called on quit / exit)."""
2013
+ Each write is attempted independently and none of them may raise: a full
2014
+ disk, a read-only cache dir or a permission hiccup has to degrade to a
2015
+ warning. Quitting must never crash, and a failed transcript save must not
2016
+ stop the usage totals (or the other way round) from being written.
2017
+ """
2018
+ def attempt(label, write):
2019
+ try:
2020
+ return write()
2021
+ except Exception as e:
2022
+ self.console.note(f"Could not save the {label}: {e}", "error")
2023
+ return None
2024
+
2025
+ attempt("history segment", lambda: save_history(
2026
+ self.messages, self.cache_dir, self.unique_id, self.history_index))
2027
+ attempt("history recorder", lambda: save_summary(
2028
+ self.summary, self.cache_dir, self.unique_id))
2029
+ usage_path = attempt("segment usage", lambda: save_usage_segment(
2030
+ self.token_stats, self.cache_dir, self.unique_id, self.history_index))
2031
+ total_path = attempt("session usage", lambda: save_total_usage(
2032
+ self.total_usage, self.cache_dir, self.unique_id))
2033
+ if usage_path or total_path:
2034
+ self.console.note(
2035
+ f"Saved usage to: {usage_path} (session total: {total_path})", "info")
2036
+
2037
+ def shutdown(self, farewell=True):
2038
+ """Persist and print a final summary (called on quit / exit).
2039
+
2040
+ ``farewell=False`` is used by the interrupt / fatal paths in :meth:`run`,
2041
+ which have already told the user (in red) that the session is being torn
2042
+ down: a cheerful "thanks and here is your state" box on top of a crash is
2043
+ the wrong note, and the state it would recap is the state that just
2044
+ failed.
2045
+ """
1646
2046
  self._persist()
2047
+ segments = history_segment_indices(self.session_dir)
2048
+ # List what is really on disk. The old message claimed
2049
+ # "history_0..{history_index}.json", which was a lie after a /reset had
2050
+ # removed the higher segments (or moved the session back onto an earlier
2051
+ # one) -- it named files that no longer existed and hid the ones that did.
2052
+ if segments:
2053
+ present = (f"history_{segments[0]}.json"
2054
+ if len(segments) == 1 else
2055
+ f"history_{segments[0]}..{segments[-1]}.json")
2056
+ else:
2057
+ present = f"history_{self.history_index}.json"
1647
2058
  self.console.note(
1648
2059
  f"Saved session to {self.cache_dir}/{self.unique_id}/ "
1649
- f"(history_0..{self.history_index}.json + summary_history.json).", "ok")
1650
- self.console.usage_summary(self.total_usage)
2060
+ f"({present} + summary_history.json).", "ok")
2061
+ # The detailed per-field usage block is verbose-only (the console
2062
+ # documents it that way); in the quiet default view the good-bye box
2063
+ # below carries the same totals, so nothing is lost by leaving it out.
2064
+ if self.console.verbose:
2065
+ self.console.usage_summary(self.total_usage)
2066
+ if farewell:
2067
+ self._farewell(segments)
2068
+
2069
+ def _farewell(self, segments=None):
2070
+ """Print the good-bye box: version, sign-off and what the session left.
2071
+
2072
+ Printed once (see ``_farewell_shown``) and only on a normal exit. It
2073
+ restates the state in one glanceable place -- the same facts the user
2074
+ would otherwise have to piece together from the save note, ``/history``
2075
+ and the (verbose-only) usage readout, which in the quiet default view
2076
+ means they saw none of it.
2077
+ """
2078
+ if self._farewell_shown:
2079
+ return
2080
+ self._farewell_shown = True
2081
+ from . import __version__ as version
2082
+
2083
+ if segments is None:
2084
+ segments = history_segment_indices(self.session_dir)
2085
+ if not segments:
2086
+ where = f"history_{self.history_index}.json"
2087
+ elif len(segments) == 1:
2088
+ where = f"history_{segments[0]}.json"
2089
+ else:
2090
+ where = f"history_{segments[0]}..{segments[-1]}.json"
2091
+
2092
+ turns = len(self.summary)
2093
+ usage = self.total_usage or {}
2094
+
2095
+ def count(key):
2096
+ # A display detail on the exit path must never raise: a server that
2097
+ # reported ``null`` usage leaves a None here, and crashing while
2098
+ # saying goodbye would be the worst possible time to fail.
2099
+ try:
2100
+ return int(usage.get(key) or 0)
2101
+ except (TypeError, ValueError):
2102
+ return 0
2103
+
2104
+ rows = [
2105
+ ("session id", self.unique_id),
2106
+ ("saved under", f"{self.cache_dir}/{self.unique_id}/"),
2107
+ ("history", f"{where} (segment {self.history_index}), "
2108
+ f"{_plural(len(self.messages), 'message')}"),
2109
+ ("conversation", _plural(turns, "recorded turn")),
2110
+ ("tokens", f"{count('prompt_tokens'):,} in / "
2111
+ f"{count('completion_tokens'):,} out "
2112
+ f"({count('total_tokens'):,} total)"),
2113
+ ("calls", f"{count('llm_calls'):,} LLM, "
2114
+ f"{count('tool_calls'):,} tool"),
2115
+ ]
2116
+ self.console.farewell(version, rows)
1651
2117
 
1652
2118
  # -- single LLM turn ---------------------------------------------------
1653
2119
 
@@ -1659,6 +2125,9 @@ class AgentSession:
1659
2125
  # rolls the session over to a fresh history segment (history_index + 1)
1660
2126
  # and returns that bump so the recorder can stay flat across it.
1661
2127
  segment_before = dict(self.token_stats)
2128
+ # The segment as it stands *before* compaction: it has to be flushed to
2129
+ # disk as it is being closed (see below), because nothing else writes it.
2130
+ closing_messages = self.messages
1662
2131
  projected = self._projected_input_tokens()
1663
2132
  self.messages, did_compact, new_index = maybe_compact(
1664
2133
  self.messages, self.server_url, self.model, self.api_key,
@@ -1682,17 +2151,33 @@ class AgentSession:
1682
2151
  # would trigger compaction again immediately.
1683
2152
  self.last_prompt_tokens = 0
1684
2153
  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)
2154
+ # Close the old segment's files before moving on: its spend is
2155
+ # preserved for the totals, and its *messages* are flushed so the
2156
+ # recorder keeps pointing at a transcript that really exists on disk.
2157
+ # Nothing else writes a closing segment -- the interactive loop only
2158
+ # persists on quit -- so without this history_{n}.json would be short
2159
+ # by every turn recorded in that segment, and /reset (or a restore)
2160
+ # would rewind to a state the user never saw.
2161
+ try:
2162
+ save_usage_segment(self.token_stats, self.cache_dir, self.unique_id,
2163
+ self.history_index)
2164
+ save_history(closing_messages, self.cache_dir, self.unique_id,
2165
+ self.history_index)
2166
+ except Exception as e:
2167
+ self.console.note(
2168
+ f"Could not save the closed history segment "
2169
+ f"history_{self.history_index}.json: {e}. The compacted "
2170
+ f"conversation is unaffected, but /reset back into that "
2171
+ f"segment may be incomplete.", "warn")
1689
2172
  self.history_index = new_index
1690
2173
  self.token_stats = empty_usage()
1691
2174
  # Snapshot the compacted messages into the new segment so its turns
1692
2175
  # are preserved, and record the summary as the first turn of it.
1693
2176
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
1694
2177
  self._record_turn(
1695
- f"summary: history compacted to {len(self.messages)} messages",
2178
+ f"summary: history_{new_index - 1}.json "
2179
+ f"({_plural(len(closing_messages), 'message')}) compacted into a "
2180
+ f"summary; history_{self.history_index}.json started",
1696
2181
  self.messages)
1697
2182
 
1698
2183
  # Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
@@ -1785,6 +2270,13 @@ class AgentSession:
1785
2270
  self.messages)
1786
2271
  elif response_text:
1787
2272
  self._record_turn("assistant", self.messages)
2273
+ else:
2274
+ # A step that produced neither text nor tool calls (a failed call, or
2275
+ # a model that answered with nothing) still appended an assistant
2276
+ # message, so it is recorded as well. The recorder must cover the
2277
+ # *final* message: otherwise a bare /reset sees an unrecorded tail
2278
+ # and refuses to touch a conversation that is in fact settled.
2279
+ self._record_turn("assistant (no response)", self.messages)
1788
2280
 
1789
2281
  return made_tool_calls and self.args.auto_mode
1790
2282
 
@@ -1794,7 +2286,7 @@ class _Abort(Exception):
1794
2286
 
1795
2287
 
1796
2288
  # ---------------------------------------------------------------------------
1797
- # module-level helpers kept for readability / backward compatibility
2289
+ # entry point
1798
2290
  # ---------------------------------------------------------------------------
1799
2291
 
1800
2292
  def main():