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.
- mini_code_cli/agent_loop.py +452 -130
- mini_code_cli/persistence.py +194 -80
- mini_code_cli/tools.py +2 -3
- mini_code_cli/utils.py +6 -13
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.1.dist-info}/METADATA +52 -14
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.1.dist-info}/RECORD +10 -10
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.1.dist-info}/WHEEL +0 -0
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.1.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.1.dist-info}/licenses/LICENSE +0 -0
- {mini_code_cli-0.1.1.0.dist-info → mini_code_cli-0.1.1.1.dist-info}/top_level.txt +0 -0
mini_code_cli/agent_loop.py
CHANGED
|
@@ -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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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 (
|
|
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="
|
|
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
|
|
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
|
|
473
|
-
so a
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
"""
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
recorder
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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.
|
|
547
|
-
self.
|
|
548
|
-
|
|
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.
|
|
570
|
-
self.
|
|
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`` (
|
|
671
|
-
running
|
|
672
|
-
compaction that may have happened before it.
|
|
673
|
-
|
|
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
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
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
|
-
|
|
827
|
-
|
|
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
|
-
|
|
844
|
-
|
|
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
|
-
|
|
852
|
-
return segment[:
|
|
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,
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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 =
|
|
868
|
-
self.summary =
|
|
869
|
-
|
|
870
|
-
|
|
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 {
|
|
873
|
-
f"(history_{
|
|
874
|
-
|
|
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
|
-
/
|
|
1367
|
-
|
|
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
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
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
|
-
|
|
1412
|
-
|
|
1675
|
+
resolved = self._resolve_turn_record(arg)
|
|
1676
|
+
if resolved is None:
|
|
1413
1677
|
return
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
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
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
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"(
|
|
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
|
|
1686
|
-
#
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
-
#
|
|
2119
|
+
# entry point
|
|
1798
2120
|
# ---------------------------------------------------------------------------
|
|
1799
2121
|
|
|
1800
2122
|
def main():
|