mini-code-cli 0.1.1.1__tar.gz → 0.1.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/PKG-INFO +23 -1
  2. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/README.md +22 -0
  3. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/agent_loop.py +197 -27
  4. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/ui.py +26 -0
  5. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/PKG-INFO +23 -1
  6. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/pyproject.toml +1 -1
  7. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_e2e_agent_loop.py +45 -1
  8. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_interactive.py +33 -0
  9. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_tokens.py +55 -4
  10. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/LICENSE +0 -0
  11. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/__init__.py +0 -0
  12. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/config.py +0 -0
  13. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/permissions.py +0 -0
  14. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/persistence.py +0 -0
  15. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/plan.py +0 -0
  16. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/prompts.py +0 -0
  17. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/runner.py +0 -0
  18. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/sandbox.py +0 -0
  19. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/shell_safety_utils.py +0 -0
  20. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/skills.py +0 -0
  21. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/subagents/__init__.py +0 -0
  22. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/subagents/base.py +0 -0
  23. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/subagents/builtin.py +0 -0
  24. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/tokens.py +0 -0
  25. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/tools.py +0 -0
  26. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli/utils.py +0 -0
  27. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/SOURCES.txt +0 -0
  28. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/dependency_links.txt +0 -0
  29. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/entry_points.txt +0 -0
  30. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/requires.txt +0 -0
  31. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/mini_code_cli.egg-info/top_level.txt +0 -0
  32. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/setup.cfg +0 -0
  33. {mini_code_cli-0.1.1.1 → mini_code_cli-0.1.1.2}/tests/test_subagents.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.1.1.1
3
+ Version: 0.1.1.2
4
4
  Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -970,6 +970,28 @@ reserve (`--max-tokens`). Unset (default) it is derived from the model's context
970
970
  output reserve and a safety margin. Lower it to compact sooner, raise it to compact later — there
971
971
  is no message-count cap to configure.
972
972
 
973
+ Compaction replaces the older turns with a summary, but it always carries the **turn in flight**
974
+ across the rollover: the message you just typed (or, mid-turn, the tool call the model is
975
+ waiting on) is kept verbatim after the summary. So a compaction that fires just as you send an
976
+ instruction cannot swallow it — the model still answers what you asked, and the turn stays in
977
+ the session file, in `/history` and in a later restore.
978
+
979
+ Because a rollover leaves no trace in the transcript (the old turns really are gone), the
980
+ console says in plain terms what happened — all of it without `--verbose`:
981
+
982
+ ```
983
+ [!] Compacting the conversation: 42 messages -> a summary (51000 tokens in, 8192 reserved for the reply > 52000 budget). The current turn (1 message) is kept verbatim.
984
+ [!] Summary preview: The user asked for a token-based compaction trigger, then fixed ...
985
+ [!] Compacted history_0.json (42 messages) into a summary; history_1.json now starts with 3 messages (summary + 1 kept message).
986
+ ```
987
+
988
+ So you can see *what* was folded away (a preview of the summary itself), *how much* (the
989
+ message count of the segment that was closed), *where it went* (`history_0.json` closed,
990
+ `history_1.json` started) and that nothing you just typed was lost. If the summariser answers
991
+ with an empty summary twice, or the call fails, compaction is abandoned and the note says so
992
+ explicitly — "nothing was lost, but the next request may still be too large" — with the
993
+ transcript and the segment left untouched.
994
+
973
995
 
974
996
  ## Serving a model locally
975
997
 
@@ -290,6 +290,28 @@ reserve (`--max-tokens`). Unset (default) it is derived from the model's context
290
290
  output reserve and a safety margin. Lower it to compact sooner, raise it to compact later — there
291
291
  is no message-count cap to configure.
292
292
 
293
+ Compaction replaces the older turns with a summary, but it always carries the **turn in flight**
294
+ across the rollover: the message you just typed (or, mid-turn, the tool call the model is
295
+ waiting on) is kept verbatim after the summary. So a compaction that fires just as you send an
296
+ instruction cannot swallow it — the model still answers what you asked, and the turn stays in
297
+ the session file, in `/history` and in a later restore.
298
+
299
+ Because a rollover leaves no trace in the transcript (the old turns really are gone), the
300
+ console says in plain terms what happened — all of it without `--verbose`:
301
+
302
+ ```
303
+ [!] Compacting the conversation: 42 messages -> a summary (51000 tokens in, 8192 reserved for the reply > 52000 budget). The current turn (1 message) is kept verbatim.
304
+ [!] Summary preview: The user asked for a token-based compaction trigger, then fixed ...
305
+ [!] Compacted history_0.json (42 messages) into a summary; history_1.json now starts with 3 messages (summary + 1 kept message).
306
+ ```
307
+
308
+ So you can see *what* was folded away (a preview of the summary itself), *how much* (the
309
+ message count of the segment that was closed), *where it went* (`history_0.json` closed,
310
+ `history_1.json` started) and that nothing you just typed was lost. If the summariser answers
311
+ with an empty summary twice, or the call fails, compaction is abandoned and the note says so
312
+ explicitly — "nothing was lost, but the next request may still be too large" — with the
313
+ transcript and the segment left untouched.
314
+
293
315
 
294
316
  ## Serving a model locally
295
317
 
@@ -267,6 +267,42 @@ def _compaction_trigger(messages, token_limit, reserved_output_tokens, current_t
267
267
  return False, ""
268
268
 
269
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
+
270
306
  def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats,
271
307
  history_index=0, token_limit=None, reserved_output_tokens=0,
272
308
  current_tokens=None, plan_hook=None, console=None):
@@ -279,7 +315,9 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
279
315
  against ``token_limit``. Nothing else triggers compaction: in particular the
280
316
  number of messages is not a criterion. Compaction is then done by asking the
281
317
  LLM to summarize the history and replacing the old turns with that summary,
282
- 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
283
321
  itself is a single, tool-free request, so it cannot trigger further tool
284
322
  calls or recurse into compaction.
285
323
 
@@ -289,10 +327,12 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
289
327
  ``plan_hook`` is an optional callable the caller supplies only when running
290
328
  under plan mode (in practice ``AgentSession._handle_compaction``). After the
291
329
  summary is built the hook re-injects the running goal + plan so the compacted
292
- messages carry them forward. It receives the compacted list and must update it
293
- *in place*, because that list is what gets returned to the caller. Passing it in -- instead of referencing the
294
- owning session -- keeps ``maybe_compact`` a small, module-level, network-free
295
- 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.
296
336
  """
297
337
  # ``console`` defaults to None so callers may omit it, but every branch below
298
338
  # reports through a console. Falling back to the shared one keeps the
@@ -311,8 +351,20 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
311
351
  # bare messages would crash them.
312
352
  return messages, False, history_index
313
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
+
314
364
  try:
315
- 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")
316
368
  summary_prompt = prompts.compaction_prompt(messages)
317
369
 
318
370
  # The summariser sometimes answers with nothing at all. An empty summary
@@ -324,7 +376,7 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
324
376
  # the over-cap transcript was retried, and paid for, on every step.
325
377
  summary = ""
326
378
  for attempt in (1, 2):
327
- console.start_thinking("compacting")
379
+ console.start_thinking("compacting history")
328
380
  try:
329
381
  reply, _, compact_usage, _ = utils.unpack_llm_result(
330
382
  utils.call_openai_server(
@@ -347,9 +399,14 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
347
399
  console.note("The summariser returned an empty summary; retrying once.", "warn")
348
400
 
349
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.
350
405
  console.note(
351
406
  f"Could not compact history: the summariser returned an empty "
352
- 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 "
353
410
  f"({reason}).", "warn")
354
411
  return messages, False, history_index
355
412
 
@@ -362,22 +419,56 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
362
419
  "content": f"Here is a summary of our earlier conversation so far:\n{summary}",
363
420
  })
364
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
+
365
433
  # In plan mode, re-inject the running plan + current goal so a
366
434
  # compaction (which would otherwise drop them) keeps the model
367
435
  # working against the goal it is still trying to finish. The hook is
368
436
  # wired in only when the caller runs under plan mode; otherwise
369
- # 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.
370
439
  if plan_hook is not None:
371
- plan_hook(compacted)
372
-
373
- 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")
374
463
  # A new segment (history_index + 1) is started on compaction: the
375
464
  # compacted messages are its snapshot, and every later turn appends
376
465
  # to it. The caller records a single high-level 'summary' turn for
377
466
  # this index so the recorder stays flat across the rollover.
378
467
  return compacted, True, history_index + 1
379
468
  except Exception as e:
380
- 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")
381
472
 
382
473
  return messages, False, history_index
383
474
 
@@ -427,6 +518,11 @@ class AgentSession:
427
518
 
428
519
  self.plan_mode = bool(getattr(self.args, "plan", False))
429
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
+
430
526
  # Token accounting. ``token_stats`` covers the *current history
431
527
  # segment* and is reset when compaction rolls over to a new one;
432
528
  # ``total_usage`` is the whole-session accumulator and never decreases.
@@ -1263,8 +1359,12 @@ class AgentSession:
1263
1359
  return
1264
1360
  except KeyboardInterrupt:
1265
1361
  self.console.note("Interrupting: Mini Code Exiting...", "error")
1362
+ self.shutdown(farewell=False)
1363
+ return
1266
1364
  except Exception as e:
1267
1365
  self.console.note(f"Exception {e}. Exiting...", "error")
1366
+ self.shutdown(farewell=False)
1367
+ return
1268
1368
  self.shutdown()
1269
1369
 
1270
1370
  def _emit_tip(self):
@@ -1508,7 +1608,7 @@ class AgentSession:
1508
1608
  return True
1509
1609
 
1510
1610
 
1511
- def _handle_compaction(self, compacted):
1611
+ def _handle_compaction(self, compacted, tail=()):
1512
1612
  """Re-inject goal + plan into the compacted messages (plan mode).
1513
1613
 
1514
1614
  Plan-mode counterpart to the plan handling inside :meth:`maybe_compact`,
@@ -1516,7 +1616,8 @@ class AgentSession:
1516
1616
  a network-free module-level helper. The loop wires in this method only
1517
1617
  when plan mode is on; it re-injects the goal + plan via
1518
1618
  :meth:`_apply_plan_to_compacted`, which also rewrites the tracking files
1519
- to match.
1619
+ to match. ``tail`` is the turn that is still in flight, which
1620
+ ``maybe_compact`` appends after the hook's messages.
1520
1621
 
1521
1622
  The hook must update ``compacted`` **in place**: ``maybe_compact``
1522
1623
  returns *that* list to the caller, which then assigns it to
@@ -1524,9 +1625,9 @@ class AgentSession:
1524
1625
  discarded a moment later, silently dropping the goal and plan -- which is
1525
1626
  exactly what happened before this was a slice assignment.
1526
1627
  """
1527
- compacted[:] = self._apply_plan_to_compacted(compacted)
1628
+ compacted[:] = self._apply_plan_to_compacted(compacted, tail)
1528
1629
 
1529
- def _apply_plan_to_compacted(self, messages):
1630
+ def _apply_plan_to_compacted(self, messages, tail=()):
1530
1631
  """Re-inject the current goal + plan after a history compaction.
1531
1632
 
1532
1633
  Compaction replaces the transcript with a summary, which would otherwise
@@ -1534,16 +1635,18 @@ class AgentSession:
1534
1635
  back off disk and append them as fresh user turns so the model keeps
1535
1636
  working against the goal it is still trying to finish. The goal is
1536
1637
  appended last so it reads as the most recent instruction the model
1537
- should act on.
1638
+ should act on -- ``maybe_compact`` then appends the in-flight turn, which
1639
+ is the same instruction verbatim.
1538
1640
  """
1539
1641
  plan_text = self.plan_manager.read_plan()
1540
1642
  goal_text = self.plan_manager.read_goal()
1541
1643
 
1542
- # Recover the user's latest instruction from the pre-compaction tail, in
1543
- # case the loop had not recorded a goal yet, so there is always a goal to
1544
- # 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.
1545
1648
  current_input = None
1546
- for m in reversed(messages):
1649
+ for m in reversed(list(tail or ())):
1547
1650
  if m.get("role") == "user":
1548
1651
  current_input = m.get("content")
1549
1652
  break
@@ -1931,8 +2034,15 @@ class AgentSession:
1931
2034
  self.console.note(
1932
2035
  f"Saved usage to: {usage_path} (session total: {total_path})", "info")
1933
2036
 
1934
- def shutdown(self):
1935
- """Persist and print a final summary (called on quit / exit)."""
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
+ """
1936
2046
  self._persist()
1937
2047
  segments = history_segment_indices(self.session_dir)
1938
2048
  # List what is really on disk. The old message claimed
@@ -1948,7 +2058,62 @@ class AgentSession:
1948
2058
  self.console.note(
1949
2059
  f"Saved session to {self.cache_dir}/{self.unique_id}/ "
1950
2060
  f"({present} + summary_history.json).", "ok")
1951
- self.console.usage_summary(self.total_usage)
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)
1952
2117
 
1953
2118
  # -- single LLM turn ---------------------------------------------------
1954
2119
 
@@ -2000,14 +2165,19 @@ class AgentSession:
2000
2165
  self.history_index)
2001
2166
  except Exception as e:
2002
2167
  self.console.note(
2003
- f"Could not save the closed history segment: {e}", "warn")
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")
2004
2172
  self.history_index = new_index
2005
2173
  self.token_stats = empty_usage()
2006
2174
  # Snapshot the compacted messages into the new segment so its turns
2007
2175
  # are preserved, and record the summary as the first turn of it.
2008
2176
  save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
2009
2177
  self._record_turn(
2010
- 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",
2011
2181
  self.messages)
2012
2182
 
2013
2183
  # Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
@@ -739,6 +739,32 @@ class Console:
739
739
  ]
740
740
  self.banner(f"MiniCodeCLI v{version}", lines, fg="cyan")
741
741
 
742
+ def farewell(self, version, rows, sign_off=None):
743
+ """Render the exit banner: a sign-off plus a summary of the session state.
744
+
745
+ Deliberately narrower than :meth:`welcome`. Everything the welcome banner
746
+ shows is about how the session *will* run -- server, model, permissions,
747
+ allowed dir -- and all of it is irrelevant once the session is over. What
748
+ the user can still act on is what the session *left behind*: which
749
+ folder it was saved in, which history segments exist, how much was said
750
+ and what it cost. So the box carries only the version, a good-bye line
751
+ and those ``rows``.
752
+
753
+ ``rows`` is a list of ``(label, value)`` pairs, laid out in a single
754
+ colon-aligned column like the welcome banner; the caller supplies the
755
+ pairs because it owns the state (the console stays free of session
756
+ policy).
757
+ """
758
+ label_w = max((len(str(label)) for label, _ in rows), default=0)
759
+ lines = [sign_off or "Goodbye, and thanks for using MiniCodeCLI!", ""]
760
+ lines += [f"{str(label).ljust(label_w)} : {value}" for label, value in rows]
761
+ # Yellow, not the welcome banner's cyan: this is the one box that
762
+ # *closes* the session, so it reads as a distinct, valedictory note
763
+ # rather than the startup banner reappearing. Colours only apply on a
764
+ # colour-capable TTY (see :meth:`box`), so piped/ASCII output is
765
+ # unchanged.
766
+ self.banner(f"MiniCodeCLI v{version} - goodbye", lines, fg="yellow")
767
+
742
768
  def announce_api_key(self, present):
743
769
  if present:
744
770
  self.note("API key found (MINI_CODE_API_KEY).", "ok")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-code-cli
3
- Version: 0.1.1.1
3
+ Version: 0.1.1.2
4
4
  Summary: Mini Coding Agent. Allows you to easily experiment with a small coding agent.
5
5
  Author-email: Nikolai Rozanov <nikolai.rozanov@gmail.com>
6
6
  License: GNU AFFERO GENERAL PUBLIC LICENSE
@@ -970,6 +970,28 @@ reserve (`--max-tokens`). Unset (default) it is derived from the model's context
970
970
  output reserve and a safety margin. Lower it to compact sooner, raise it to compact later — there
971
971
  is no message-count cap to configure.
972
972
 
973
+ Compaction replaces the older turns with a summary, but it always carries the **turn in flight**
974
+ across the rollover: the message you just typed (or, mid-turn, the tool call the model is
975
+ waiting on) is kept verbatim after the summary. So a compaction that fires just as you send an
976
+ instruction cannot swallow it — the model still answers what you asked, and the turn stays in
977
+ the session file, in `/history` and in a later restore.
978
+
979
+ Because a rollover leaves no trace in the transcript (the old turns really are gone), the
980
+ console says in plain terms what happened — all of it without `--verbose`:
981
+
982
+ ```
983
+ [!] Compacting the conversation: 42 messages -> a summary (51000 tokens in, 8192 reserved for the reply > 52000 budget). The current turn (1 message) is kept verbatim.
984
+ [!] Summary preview: The user asked for a token-based compaction trigger, then fixed ...
985
+ [!] Compacted history_0.json (42 messages) into a summary; history_1.json now starts with 3 messages (summary + 1 kept message).
986
+ ```
987
+
988
+ So you can see *what* was folded away (a preview of the summary itself), *how much* (the
989
+ message count of the segment that was closed), *where it went* (`history_0.json` closed,
990
+ `history_1.json` started) and that nothing you just typed was lost. If the summariser answers
991
+ with an empty summary twice, or the call fails, compaction is abandoned and the note says so
992
+ explicitly — "nothing was lost, but the next request may still be too large" — with the
993
+ transcript and the segment left untouched.
994
+
973
995
 
974
996
  ## Serving a model locally
975
997
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "mini-code-cli"
3
3
  # version = { file = "mini_code_cli/__init__.py", attr = "__version__" }
4
- version = "0.1.1.1"
4
+ version = "0.1.1.2"
5
5
  description = "Mini Coding Agent. Allows you to easily experiment with a small coding agent."
6
6
  readme = "README.md"
7
7
  requires-python = ">=3.10"
@@ -522,7 +522,7 @@ class CompactionTest(unittest.TestCase):
522
522
  messages = self._messages(4)
523
523
  out, did_compact, _ = self._compact(
524
524
  messages, token_limit=10, current_tokens=50,
525
- plan_hook=lambda msgs: msgs.append({"role": "user", "content": "GOAL + PLAN"}))
525
+ plan_hook=lambda msgs, tail: msgs.append({"role": "user", "content": "GOAL + PLAN"}))
526
526
 
527
527
  self.assertTrue(did_compact)
528
528
  self.assertEqual(out[-1]["content"], "GOAL + PLAN")
@@ -714,6 +714,50 @@ class TokenBudgetCompactionTest(unittest.TestCase):
714
714
  # Atomic writes: nothing half-written is left lying around.
715
715
  self.assertEqual([n for n in os.listdir(session_dir) if ".tmp." in n], [])
716
716
 
717
+ def test_the_typed_turn_survives_the_rollover(self):
718
+ """The line typed just before a compaction is still the turn answered.
719
+
720
+ The bug: the rollover replaced the transcript with (system, summary), so
721
+ the user message this step existed to answer was summarised away. The
722
+ model answered the summary ("Understood. What would you like me to
723
+ do?"), and the typed line was missing from the live transcript, from the
724
+ new segment, from the recorder's target and therefore from a restore --
725
+ from the user's side, they typed an instruction and nothing happened.
726
+ """
727
+ session = self._session(max_context_tokens=1)
728
+ typed = "please fix the parser bug in main.py"
729
+ session.messages.append({"role": "user", "content": typed})
730
+
731
+ self._run_step(session, [
732
+ ("A summary of the work so far.", [], USAGE), # the compaction call
733
+ ("Understood. Fixing the parser.", [], USAGE), # the step's own call
734
+ ])
735
+ session.shutdown()
736
+
737
+ # 1) the model was asked to answer the typed line, not the summary.
738
+ self.assertEqual(session.messages[-2], {"role": "user", "content": typed})
739
+ self.assertIn("A summary of the work so far.", session.messages[-3]["content"])
740
+
741
+ # 2) the recorder's last record reaches it, so /history and /reset can.
742
+ last = session.summary[-1]
743
+ self.assertEqual(last["history_file"], session.history_index)
744
+ self.assertIn(typed, [m.get("content") for m in session._segment_upto(last)])
745
+
746
+ # 3) it is in the segment the recorder points at, so a restore has it.
747
+ session_dir = os.path.join(self.cache_dir, session.unique_id)
748
+ with open(os.path.join(session_dir, f"history_{last['history_file']}.json"),
749
+ "r", encoding="utf-8") as f:
750
+ segment = json.load(f)
751
+ self.assertIn(typed, [m.get("content") for m in segment])
752
+
753
+ args = make_args(self.workdir, self.cache_dir)
754
+ args.restore_session = session_dir
755
+ restored = AgentSession(args, console=silent_console())
756
+ restored.start_session()
757
+ self.assertEqual([m.get("content") for m in restored.messages],
758
+ [m.get("content") for m in session.messages])
759
+ self.assertIn(typed, [m.get("content") for m in restored.messages])
760
+
717
761
  def test_the_stale_prompt_report_is_dropped_after_a_rollover(self):
718
762
  session = self._session(max_context_tokens=1)
719
763
  session.last_prompt_tokens = 12_345
@@ -941,6 +941,35 @@ class CompactionEdgeCaseTest(unittest.TestCase):
941
941
  console=self.console())
942
942
  return result, stub
943
943
 
944
+ def test_success_reports_what_was_compacted_where_and_what_was_kept(self):
945
+ """The default (non-verbose) console has to answer the user's questions.
946
+
947
+ A rollover leaves no trace in the transcript, so the notes are the only
948
+ thing the user has: they must say *what* was folded away (a summary
949
+ preview), *how much* (before -> after), *where it went* (which segment
950
+ file was closed and which was started) and that the instruction being
951
+ answered was not lost. All of it is ``warn``, i.e. visible without
952
+ ``--verbose``.
953
+ """
954
+ messages = [{"role": "system", "content": "S"},
955
+ {"role": "user", "content": "q"},
956
+ {"role": "assistant", "content": "a"},
957
+ {"role": "user", "content": "the new instruction"}]
958
+ (out, did_compact, index), _ = self._compact(
959
+ messages, ["The user asked a question and got an answer."])
960
+ text = self.output()
961
+
962
+ self.assertTrue(did_compact)
963
+ self.assertEqual(len(out), 3) # system + summary + turn in flight
964
+ # the summary content itself, not just a message count
965
+ self.assertIn("Summary preview: The user asked a question", text)
966
+ # how much was folded away, and which segment file it went into
967
+ self.assertIn("Compacted history_0.json (4 messages) into a summary", text)
968
+ self.assertIn("history_1.json now starts with 3 messages", text)
969
+ self.assertIn("summary + 1 kept message", text)
970
+ # and that the instruction this step is answering survived the rollover
971
+ self.assertIn("The current turn (1 message) is kept verbatim", text)
972
+
944
973
  def test_compaction_prompt_tolerates_missing_content(self):
945
974
  """A tool-call-only turn (content None or absent) must not raise KeyError."""
946
975
  messages = [
@@ -972,6 +1001,9 @@ class CompactionEdgeCaseTest(unittest.TestCase):
972
1001
  self.assertEqual(len(stub.calls), 2) # exactly one retry
973
1002
  self.assertEqual(self.stats["llm_calls"], 2) # both attempts were billed
974
1003
  self.assertIn("Could not compact history", self.output())
1004
+ # ... and it must say that nothing was lost, not just that it failed.
1005
+ self.assertIn("nothing was lost", self.output())
1006
+ self.assertIn("Keeping all 3 messages and history_0.json", self.output())
975
1007
 
976
1008
  def test_empty_summary_then_a_real_one_still_compacts(self):
977
1009
  """A single empty reply is recovered from by the retry."""
@@ -1002,6 +1034,7 @@ class CompactionEdgeCaseTest(unittest.TestCase):
1002
1034
  self.assertEqual(index, 0)
1003
1035
  self.assertIs(out, messages)
1004
1036
  self.assertIn("Failed to compact history", self.output())
1037
+ self.assertIn("Keeping all 3 messages unchanged", self.output())
1005
1038
 
1006
1039
 
1007
1040
  if __name__ == "__main__":
@@ -289,9 +289,11 @@ class CompactionTriggerTest(unittest.TestCase):
289
289
  self.assertTrue(did_compact)
290
290
  self.assertEqual(index, 1)
291
291
  # No system prompt to preserve, so the old turns collapse to one summary
292
- # turn -- from two ~1k-token messages to one short one.
293
- self.assertEqual(len(out), 1)
292
+ # turn -- plus the second (unanswered) user message, which is the turn in
293
+ # flight and must reach the model, not just the summariser.
294
+ self.assertEqual(len(out), 2)
294
295
  self.assertIn("A short summary.", out[0]["content"])
296
+ self.assertEqual(out[1], messages[-1])
295
297
  self.assertEqual(self.stats["llm_calls"], 1)
296
298
 
297
299
  def test_a_large_transcript_under_the_budget_does_not_compact(self):
@@ -365,6 +367,52 @@ class CompactionTriggerTest(unittest.TestCase):
365
367
  self.assertEqual(index, 0)
366
368
  self.assertEqual(self.stats["llm_calls"], 0) # no summariser call
367
369
 
370
+ # -- the turn in flight survives the rollover --------------------------
371
+
372
+ def test_a_fresh_user_message_is_not_summarised_away(self):
373
+ """The line just typed must reach the model, not only the summariser.
374
+
375
+ Compaction used to return (system, summary) only, so the message the
376
+ next request was supposed to answer was gone: the model answered the
377
+ summary instead and the typed line never appeared again.
378
+ """
379
+ messages = [{"role": "system", "content": "SYSTEM"},
380
+ {"role": "user", "content": "earlier question"},
381
+ {"role": "assistant", "content": "earlier answer"},
382
+ {"role": "user", "content": "the new instruction"}]
383
+ out, did_compact, _ = self._compact(
384
+ messages, token_limit=1_000, current_tokens=4_000)
385
+
386
+ self.assertTrue(did_compact)
387
+ self.assertEqual([m["role"] for m in out], ["system", "user", "user"])
388
+ self.assertIn("A short summary.", out[1]["content"])
389
+ self.assertEqual(out[2], {"role": "user", "content": "the new instruction"})
390
+
391
+ def test_a_tool_exchange_in_flight_is_carried_over(self):
392
+ """Compacting mid tool loop keeps the call *and* the result it awaits."""
393
+ messages = [{"role": "system", "content": "SYSTEM"},
394
+ {"role": "user", "content": "read the file"},
395
+ {"role": "assistant", "content": "", "tool_calls": [{"id": "c1"}]},
396
+ {"role": "tool", "tool_call_id": "c1", "content": "file contents"}]
397
+ out, did_compact, _ = self._compact(
398
+ messages, token_limit=1_000, current_tokens=4_000)
399
+
400
+ self.assertTrue(did_compact)
401
+ self.assertEqual([m["role"] for m in out],
402
+ ["system", "user", "user", "assistant", "tool"])
403
+ self.assertEqual(out[-1]["content"], "file contents")
404
+
405
+ def test_an_answered_turn_adds_nothing(self):
406
+ """A transcript ending in an assistant reply has no turn in flight."""
407
+ messages = [{"role": "system", "content": "SYSTEM"},
408
+ {"role": "user", "content": "question"},
409
+ {"role": "assistant", "content": "answer"}]
410
+ out, did_compact, _ = self._compact(
411
+ messages, token_limit=1_000, current_tokens=4_000)
412
+
413
+ self.assertTrue(did_compact)
414
+ self.assertEqual([m["role"] for m in out], ["system", "user"])
415
+
368
416
  # -- unchanged behaviour ----------------------------------------------
369
417
 
370
418
  def test_an_explicit_limit_is_honoured(self):
@@ -389,11 +437,14 @@ class CompactionTriggerTest(unittest.TestCase):
389
437
  def test_the_plan_hook_still_reinjects_the_goal_and_plan(self):
390
438
  out, did_compact, _ = self._compact(
391
439
  long_messages(2, 8_000), token_limit=1_000, current_tokens=4_000,
392
- plan_hook=lambda msgs: msgs.append(
440
+ plan_hook=lambda msgs, tail: msgs.append(
393
441
  {"role": "user", "content": "GOAL + PLAN"}))
394
442
 
395
443
  self.assertTrue(did_compact)
396
- self.assertEqual(out[-1]["content"], "GOAL + PLAN")
444
+ # The goal/plan context sits between the summary and the live turn, so
445
+ # the user's actual instruction stays the last thing the model reads.
446
+ self.assertEqual(out[1]["content"], "GOAL + PLAN")
447
+ self.assertEqual(out[2]["role"], "user") # the in-flight turn
397
448
 
398
449
  def test_an_empty_summary_keeps_the_transcript_and_the_segment(self):
399
450
  messages = long_messages(2, 8_000)
File without changes