mini-code-cli 0.1.0.1__tar.gz → 0.1.0.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.
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/PKG-INFO +2 -2
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/README.md +1 -1
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/agent_loop.py +35 -11
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/persistence.py +107 -2
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/ui.py +10 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/PKG-INFO +2 -2
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/SOURCES.txt +1 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/pyproject.toml +1 -1
- mini_code_cli-0.1.0.2/tests/test_last_session_pointer.py +218 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_ui.py +18 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/LICENSE +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/__init__.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/config.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/permissions.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/plan.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/prompts.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/runner.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/sandbox.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/shell_safety_utils.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/skills.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/__init__.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/base.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/subagents/builtin.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/tools.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli/utils.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/dependency_links.txt +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/entry_points.txt +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/requires.txt +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/mini_code_cli.egg-info/top_level.txt +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/setup.cfg +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_agent_loop.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_integration.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_plan.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_sandboxing.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_shell_safety.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_skills.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_subagents.py +0 -0
- {mini_code_cli-0.1.0.1 → mini_code_cli-0.1.0.2}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.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
|
|
@@ -720,7 +720,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
|
|
|
720
720
|
- allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
|
|
721
721
|
- ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
|
|
722
722
|
- system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
|
|
723
|
-
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session
|
|
723
|
+
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
|
|
724
724
|
- plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
|
|
725
725
|
|
|
726
726
|
### Available tools
|
|
@@ -46,7 +46,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
|
|
|
46
46
|
- allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
|
|
47
47
|
- ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
|
|
48
48
|
- system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
|
|
49
|
-
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session
|
|
49
|
+
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
|
|
50
50
|
- plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
|
|
51
51
|
|
|
52
52
|
### Available tools
|
|
@@ -52,6 +52,7 @@ from .persistence import (
|
|
|
52
52
|
load_summary_from_dir,
|
|
53
53
|
load_usage_from_dir,
|
|
54
54
|
find_last_session_dir,
|
|
55
|
+
save_last_session_pointer,
|
|
55
56
|
accumulate_usage,
|
|
56
57
|
generate_unique_id,
|
|
57
58
|
_restore_dir,
|
|
@@ -368,7 +369,15 @@ class AgentSession:
|
|
|
368
369
|
restore_session = getattr(self.args, "restore_session", None)
|
|
369
370
|
self.loaded_last_session_dir = None
|
|
370
371
|
if getattr(self.args, "load_last_session", False) and not restore_session:
|
|
371
|
-
|
|
372
|
+
# Resolve the most recent session: the .mini pointer recorded under
|
|
373
|
+
# --allowed-dir is consulted first (an explicit record of the last
|
|
374
|
+
# session worked in), falling back to the timestamp heuristic over
|
|
375
|
+
# the cache dir.
|
|
376
|
+
detected = find_last_session_dir(
|
|
377
|
+
self.cache_dir,
|
|
378
|
+
allowed_dir=self.allowed_dir,
|
|
379
|
+
use_pointer=True,
|
|
380
|
+
)
|
|
372
381
|
if detected:
|
|
373
382
|
restore_session = detected
|
|
374
383
|
self.loaded_last_session_dir = detected
|
|
@@ -585,6 +594,13 @@ class AgentSession:
|
|
|
585
594
|
# user can see / copy the id this conversation is being saved under.
|
|
586
595
|
self._print_session_id()
|
|
587
596
|
|
|
597
|
+
# Record this session as the "latest" so a future --load-last-session can
|
|
598
|
+
# resume it without scanning the cache dir. This lives in the top-level
|
|
599
|
+
# loop's own directory (cache_dir/unique_id), and only the interactive
|
|
600
|
+
# run() writes it -- a sub-agent child drives via _drive_auto and would
|
|
601
|
+
# otherwise overwrite the pointer with its own buried path.
|
|
602
|
+
self._save_last_session_pointer()
|
|
603
|
+
|
|
588
604
|
# Report the outcome of --load-last-session up front, so the user sees
|
|
589
605
|
# whether we resumed an earlier conversation (under a brand-new id) or
|
|
590
606
|
# started fresh because no previous session was found.
|
|
@@ -616,6 +632,7 @@ class AgentSession:
|
|
|
616
632
|
# 1) Get the user's turn: the one-shot preset, or a fresh line.
|
|
617
633
|
if pending is not None:
|
|
618
634
|
self.console.rule(title="Preset prompt", char="=", fg="cyan")
|
|
635
|
+
self.console.short_divider()
|
|
619
636
|
self.console.turn("You", pending, accent="cyan")
|
|
620
637
|
user_input = pending
|
|
621
638
|
pending = None
|
|
@@ -654,18 +671,24 @@ class AgentSession:
|
|
|
654
671
|
self._persist()
|
|
655
672
|
|
|
656
673
|
def _print_session_id(self):
|
|
657
|
-
"""Print the session's unique id as
|
|
674
|
+
"""Print the session's unique id as a minimal "Assistant" turn.
|
|
658
675
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
676
|
+
Instead of a banner/header the id is rendered as an assistant-styled
|
|
677
|
+
(green) turn carrying *only* the id in brackets, so the user can see /
|
|
678
|
+
copy the id this conversation is being saved under without the noise of
|
|
679
|
+
the extra "saved under" line.
|
|
663
680
|
"""
|
|
664
|
-
self.console.
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
681
|
+
self.console.turn("Assistant", f"[{self.unique_id}]", accent="green")
|
|
682
|
+
|
|
683
|
+
def _save_last_session_pointer(self):
|
|
684
|
+
"""Record this session's directory in --allowed-dir/.mini.
|
|
685
|
+
|
|
686
|
+
Writes the .mini pointer that a later --load-last-session consults first
|
|
687
|
+
(see save_last_session_pointer). Best-effort: never raises, so a pointer
|
|
688
|
+
hiccup can never break the interactive session.
|
|
689
|
+
"""
|
|
690
|
+
save_last_session_pointer(self.allowed_dir,
|
|
691
|
+
os.path.join(self.cache_dir, self.unique_id))
|
|
669
692
|
|
|
670
693
|
# -- autonomous drive (used by sub-agents) ------------------------------
|
|
671
694
|
|
|
@@ -833,6 +856,7 @@ class AgentSession:
|
|
|
833
856
|
|
|
834
857
|
def _read_user_line(self):
|
|
835
858
|
"""Read one turn from the user. Returns the text to send, or None to quit."""
|
|
859
|
+
self.console.short_divider()
|
|
836
860
|
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
837
861
|
if user_input is None: # EOF / Ctrl-C at the prompt
|
|
838
862
|
self.console.note("Thank you. Goodbye! Saving history...", "info")
|
|
@@ -24,6 +24,7 @@ raising.
|
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
26
|
import re
|
|
27
|
+
import time
|
|
27
28
|
|
|
28
29
|
from . import ui
|
|
29
30
|
from .config import DEFAULT_CACHE_LOCATION
|
|
@@ -76,8 +77,112 @@ def generate_unique_id():
|
|
|
76
77
|
return f"{timestamp}-{uuid.uuid4().hex[:8]}"
|
|
77
78
|
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# latest-session pointer (.mini)
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
# The .mini directory (inside the user's --allowed-dir) holds a tiny pointer to
|
|
85
|
+
# the most recent session. --load-last-session consults this pointer *first*
|
|
86
|
+
# (an explicit opt-in the user's environment recorded) before falling back to
|
|
87
|
+
# the timestamp heuristic in find_last_session_dir().
|
|
88
|
+
#
|
|
89
|
+
# NOTE: .mini is also the skills feature's directory. The pointer is a single
|
|
90
|
+
# dedicated file inside it (LAST_SESSION_FILENAME), so the two never collide and
|
|
91
|
+
# the skills code (which only reads <skill-name>/ + SKILLS.md) is unaffected.
|
|
92
|
+
SKILLS_DIRNAME = ".mini"
|
|
93
|
+
LAST_SESSION_FILENAME = "last_session.json"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _last_session_dir_store(allowed_dir):
|
|
97
|
+
"""Return the .mini directory for ``allowed_dir`` (creating it if needed).
|
|
98
|
+
|
|
99
|
+
Best-effort: never raises. Returns None if the directory cannot be ensured,
|
|
100
|
+
in which case the pointer is simply not consulted/updated.
|
|
101
|
+
"""
|
|
102
|
+
if not allowed_dir:
|
|
103
|
+
return None
|
|
104
|
+
store = os.path.join(os.path.abspath(allowed_dir), SKILLS_DIRNAME)
|
|
105
|
+
try:
|
|
106
|
+
os.makedirs(store, exist_ok=True)
|
|
107
|
+
return store
|
|
108
|
+
except OSError as e:
|
|
109
|
+
ui.console.note(f"Could not use {store} for the latest-session pointer: {e}", "warn")
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def save_last_session_pointer(allowed_dir, session_dir):
|
|
114
|
+
"""Record ``session_dir`` as the latest session in ``allowed_dir/.mini``.
|
|
115
|
+
|
|
116
|
+
Writes a tiny JSON file (the absolute path plus a timestamp) so a later
|
|
117
|
+
--load-last-session run can resume the most recent session without scanning
|
|
118
|
+
the cache dir. Best-effort: never raises, returns the written path or None.
|
|
119
|
+
"""
|
|
120
|
+
if not session_dir:
|
|
121
|
+
return None
|
|
122
|
+
store = _last_session_dir_store(allowed_dir)
|
|
123
|
+
if store is None:
|
|
124
|
+
return None
|
|
125
|
+
payload = {
|
|
126
|
+
"session_dir": os.path.abspath(session_dir),
|
|
127
|
+
"recorded_at": time.time(),
|
|
128
|
+
}
|
|
129
|
+
path = os.path.join(store, LAST_SESSION_FILENAME)
|
|
130
|
+
try:
|
|
131
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
132
|
+
json.dump(payload, f, indent=2)
|
|
133
|
+
return path
|
|
134
|
+
except OSError as e:
|
|
135
|
+
ui.console.note(f"Could not record latest-session pointer: {e}", "warn")
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_last_session_pointer(allowed_dir):
|
|
140
|
+
"""Read the latest-session pointer recorded under ``allowed_dir/.mini``.
|
|
141
|
+
|
|
142
|
+
Returns the recorded session directory (a path into the cache folder), or
|
|
143
|
+
None if no pointer exists or it cannot be read. Best-effort: a missing or
|
|
144
|
+
malformed pointer simply yields None so the caller falls back to the
|
|
145
|
+
timestamp heuristic.
|
|
146
|
+
"""
|
|
147
|
+
if not allowed_dir:
|
|
148
|
+
return None
|
|
149
|
+
store = os.path.join(os.path.abspath(allowed_dir), SKILLS_DIRNAME)
|
|
150
|
+
path = os.path.join(store, LAST_SESSION_FILENAME)
|
|
151
|
+
if not os.path.isfile(path):
|
|
152
|
+
return None
|
|
153
|
+
try:
|
|
154
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
155
|
+
payload = json.load(f)
|
|
156
|
+
except Exception:
|
|
157
|
+
return None
|
|
158
|
+
if not isinstance(payload, dict):
|
|
159
|
+
return None
|
|
160
|
+
session_dir = payload.get("session_dir")
|
|
161
|
+
if not session_dir or not os.path.isdir(session_dir):
|
|
162
|
+
return None
|
|
163
|
+
return session_dir
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def find_last_session_dir(cache_dir=None, allowed_dir=None, use_pointer=True):
|
|
167
|
+
"""Return the path to the most recently *active* session dir.
|
|
168
|
+
|
|
169
|
+
When ``use_pointer`` and an ``allowed_dir`` are given, the .mini pointer
|
|
170
|
+
recorded by a previous run (see save_last_session_pointer) is consulted
|
|
171
|
+
*first* -- it is an explicit record of the session the user last worked in.
|
|
172
|
+
Only if that yields nothing do we fall back to the timestamp heuristic over
|
|
173
|
+
``cache_dir`` (find_last_session_dir_by_mtime). Session folders with no
|
|
174
|
+
files are ignored. Never raises: a missing/unreadable cache dir simply
|
|
175
|
+
yields None.
|
|
176
|
+
"""
|
|
177
|
+
if use_pointer and allowed_dir:
|
|
178
|
+
pointed = load_last_session_pointer(allowed_dir)
|
|
179
|
+
if pointed:
|
|
180
|
+
return pointed
|
|
181
|
+
return find_last_session_dir_by_mtime(cache_dir)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def find_last_session_dir_by_mtime(cache_dir):
|
|
185
|
+
"""Timestamp heuristic for the most recently *active* session dir.
|
|
81
186
|
|
|
82
187
|
"Most recent" is decided by the newest modification time of any file inside
|
|
83
188
|
each session folder (a session rewrites its ``history_{n}.json`` every step),
|
|
@@ -371,6 +371,16 @@ class Console:
|
|
|
371
371
|
"""
|
|
372
372
|
self.emit(self.style("-" * self.width, fg="gray", dim=True))
|
|
373
373
|
|
|
374
|
+
def short_divider(self, char="-", width=8, fg="gray"):
|
|
375
|
+
"""Render a short horizontal delimiter line (not the full width).
|
|
376
|
+
|
|
377
|
+
Used to set off the user's own "You:" turn from the preceding assistant
|
|
378
|
+
reply with a light, unobtrusive rule rather than a full-width divider.
|
|
379
|
+
Defaults to a plain ASCII dash so it renders consistently regardless of
|
|
380
|
+
the console's unicode setting.
|
|
381
|
+
"""
|
|
382
|
+
self.emit(self.style(char * width, fg=fg, dim=True))
|
|
383
|
+
|
|
374
384
|
def clear_screen(self):
|
|
375
385
|
"""Clear the terminal and move the cursor to the top-left.
|
|
376
386
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mini-code-cli
|
|
3
|
-
Version: 0.1.0.
|
|
3
|
+
Version: 0.1.0.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
|
|
@@ -720,7 +720,7 @@ mini-code --enable-shell --auto-mode --agent-md "./skill.md" --allowed-dir "./"
|
|
|
720
720
|
- allowed-dir: this specifies which dirs the agent can read and write from, that does not stop the shell comands! (though dangerous shell commands are still blocked by the classifier above).
|
|
721
721
|
- ask-permission: asks user permission before every tool call. For shell commands, commands in the ALLOW tier skip the prompt, DENY is blocked, and ASK always prompts.
|
|
722
722
|
- system-prompt: you can override the default prompt by using a string. (Note: Agent.md is always appended after the system prompt.)
|
|
723
|
-
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session
|
|
723
|
+
- load-last-session: instead of starting a fresh conversation, automatically continue your most recently active session from the default cache dir. It resumes under a brand-new session id, so the original session is never overwritten. An explicit `--restore-session` (a specific path) takes precedence if both are given. The "most recent" session is resolved in two steps: at the end of every interactive session, its path is recorded in a tiny `.mini/last_session.json` pointer inside the `--allowed-dir`, and `--load-last-session` consults that pointer first; only if there is no pointer (e.g. a fresh allowed dir, or the `--skills` feature disabled) does it fall back to the timestamp heuristic (the session whose history was modified most recently). The `.mini` pointer is the same directory the `--skills` feature uses, but it is a single dedicated file, so the two never clash.
|
|
724
724
|
- plan: `--plan` enables **plan mode**. The agent now keeps two tracking files in the session directory: `CURRENT_GOAL.md` (the user's latest instruction) and `CURRENT_PLAN.md` (its own step-by-step plan, which it must update as it works and mark finished once the goal is done). The system message gets an extra instruction telling the agent to do this. Plan mode also changes how history compaction works: after the history is compacted to a summary, the current `CURRENT_PLAN.md` and the user's last instruction (`CURRENT_GOAL.md`) are re-injected into the messages so the agent keeps working against the same goal. When the session ends, the active goal is marked finished in `CURRENT_PLAN.md`.
|
|
725
725
|
|
|
726
726
|
### Available tools
|
|
@@ -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.0.
|
|
4
|
+
version = "0.1.0.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"
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Tests for the `.mini` latest-session pointer used by --load-last-session.
|
|
2
|
+
|
|
3
|
+
A tiny pointer is recorded in `--allowed-dir/.mini/last_session.json` at the
|
|
4
|
+
end of every interactive session. `--load-last-session` consults that pointer
|
|
5
|
+
*first* (an explicit record of the session the user last worked in) and only
|
|
6
|
+
falls back to the timestamp heuristic over the cache dir when there is none.
|
|
7
|
+
"""
|
|
8
|
+
import io
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import types
|
|
12
|
+
import tempfile
|
|
13
|
+
import json
|
|
14
|
+
import shutil
|
|
15
|
+
import unittest
|
|
16
|
+
from unittest.mock import patch
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
19
|
+
|
|
20
|
+
from mini_code_cli import agent_loop, ui, persistence, skills
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _make_args(**kw):
|
|
24
|
+
a = types.SimpleNamespace(
|
|
25
|
+
url="http://x", model="m", allowed_dir=".", cache_dir=None, api_key=None,
|
|
26
|
+
system_prompt=None, agent_md=None, prompt=None, enable_shell=False,
|
|
27
|
+
ask_permission=False, auto_mode=False, restore_session=None, llm_timeout=30,
|
|
28
|
+
tool_timeout=30, load_last_session=False, verbose=False, max_tokens=None,
|
|
29
|
+
temperature=1.0, plan=False, enable_subagents=False, enable_skills=False,
|
|
30
|
+
sudo=False,
|
|
31
|
+
)
|
|
32
|
+
a.__dict__.update(kw)
|
|
33
|
+
return a
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TestPointerBasics(unittest.TestCase):
|
|
37
|
+
def setUp(self):
|
|
38
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
39
|
+
self.addCleanup(self.tmp.cleanup)
|
|
40
|
+
self.allowed = self.tmp.name
|
|
41
|
+
|
|
42
|
+
def _session_dir(self, name="sess"):
|
|
43
|
+
d = os.path.join(self.tmp.name, "cache", name)
|
|
44
|
+
os.makedirs(d, exist_ok=True)
|
|
45
|
+
open(os.path.join(d, "history.json"), "w").write("[]")
|
|
46
|
+
return d
|
|
47
|
+
|
|
48
|
+
def test_save_then_load_roundtrip(self):
|
|
49
|
+
sdir = self._session_dir("one")
|
|
50
|
+
path = persistence.save_last_session_pointer(self.allowed, sdir)
|
|
51
|
+
self.assertIsNotNone(path)
|
|
52
|
+
self.assertTrue(os.path.isfile(path))
|
|
53
|
+
payload = json.load(open(path))
|
|
54
|
+
self.assertEqual(payload["session_dir"], os.path.abspath(sdir))
|
|
55
|
+
self.assertIn("recorded_at", payload)
|
|
56
|
+
self.assertEqual(persistence.load_last_session_pointer(self.allowed),
|
|
57
|
+
os.path.abspath(sdir))
|
|
58
|
+
|
|
59
|
+
def test_load_missing_returns_none(self):
|
|
60
|
+
self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
|
|
61
|
+
|
|
62
|
+
def test_load_malformed_returns_none(self):
|
|
63
|
+
persistence.save_last_session_pointer(self.allowed, self._session_dir("x"))
|
|
64
|
+
p = os.path.join(self.allowed, ".mini", persistence.LAST_SESSION_FILENAME)
|
|
65
|
+
open(p, "w").write("not json {[")
|
|
66
|
+
self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
|
|
67
|
+
|
|
68
|
+
def test_load_pointing_to_missing_dir_returns_none(self):
|
|
69
|
+
path = persistence.save_last_session_pointer(self.allowed, self._session_dir("x"))
|
|
70
|
+
shutil.rmtree(os.path.join(self.tmp.name, "cache", "x"))
|
|
71
|
+
self.assertIsNone(persistence.load_last_session_pointer(self.allowed))
|
|
72
|
+
|
|
73
|
+
def test_save_none_is_a_noop(self):
|
|
74
|
+
self.assertIsNone(persistence.save_last_session_pointer(self.allowed, None))
|
|
75
|
+
self.assertIsNone(
|
|
76
|
+
persistence.load_last_session_pointer(self.allowed))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestFindLastSessionResolution(unittest.TestCase):
|
|
80
|
+
def setUp(self):
|
|
81
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
82
|
+
self.addCleanup(self.tmp.cleanup)
|
|
83
|
+
self.allowed = self.tmp.name
|
|
84
|
+
self.cache = os.path.join(self.tmp.name, "cache")
|
|
85
|
+
|
|
86
|
+
def _session_dir(self, name):
|
|
87
|
+
d = os.path.join(self.cache, name)
|
|
88
|
+
os.makedirs(d, exist_ok=True)
|
|
89
|
+
f = os.path.join(d, "history.json")
|
|
90
|
+
open(f, "w").write("[]")
|
|
91
|
+
return d, f
|
|
92
|
+
|
|
93
|
+
def test_pointer_wins_over_newer_mtime(self):
|
|
94
|
+
s1, _ = self._session_dir("older")
|
|
95
|
+
s2, f2 = self._session_dir("newer")
|
|
96
|
+
os.utime(f2, (1e12, 1e12)) # far in the future -> newest by mtime
|
|
97
|
+
# Record a pointer to s1 (the older one).
|
|
98
|
+
persistence.save_last_session_pointer(self.allowed, s1)
|
|
99
|
+
got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
|
|
100
|
+
use_pointer=True)
|
|
101
|
+
self.assertEqual(got, os.path.abspath(s1))
|
|
102
|
+
|
|
103
|
+
def test_falls_back_to_mtime_when_no_pointer(self):
|
|
104
|
+
s1, _ = self._session_dir("older")
|
|
105
|
+
s2, f2 = self._session_dir("newer")
|
|
106
|
+
os.utime(f2, (1e12, 1e12))
|
|
107
|
+
got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
|
|
108
|
+
use_pointer=True)
|
|
109
|
+
self.assertEqual(got, os.path.abspath(s2))
|
|
110
|
+
|
|
111
|
+
def test_use_pointer_false_ignores_pointer(self):
|
|
112
|
+
s1, _ = self._session_dir("older")
|
|
113
|
+
s2, f2 = self._session_dir("newer")
|
|
114
|
+
os.utime(f2, (1e12, 1e12))
|
|
115
|
+
persistence.save_last_session_pointer(self.allowed, s1)
|
|
116
|
+
got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
|
|
117
|
+
use_pointer=False)
|
|
118
|
+
self.assertEqual(got, os.path.abspath(s2))
|
|
119
|
+
|
|
120
|
+
def test_none_when_everything_empty(self):
|
|
121
|
+
os.makedirs(self.cache, exist_ok=True)
|
|
122
|
+
got = persistence.find_last_session_dir(self.cache, allowed_dir=self.allowed,
|
|
123
|
+
use_pointer=True)
|
|
124
|
+
self.assertIsNone(got)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TestPointerAndSkillsCoexist(unittest.TestCase):
|
|
128
|
+
def setUp(self):
|
|
129
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
130
|
+
self.addCleanup(self.tmp.cleanup)
|
|
131
|
+
self.allowed = self.tmp.name
|
|
132
|
+
|
|
133
|
+
def test_pointer_file_does_not_appear_as_a_skill(self):
|
|
134
|
+
# Use skills so .mini + SKILLS.md exist.
|
|
135
|
+
skills.list_skills(self.allowed)
|
|
136
|
+
# A session records its pointer in the same .mini.
|
|
137
|
+
cache = os.path.join(self.tmp.name, "cache")
|
|
138
|
+
sdir = os.path.join(cache, "abc")
|
|
139
|
+
os.makedirs(sdir); open(os.path.join(sdir, "history.json"), "w").write("[]")
|
|
140
|
+
persistence.save_last_session_pointer(self.allowed, sdir)
|
|
141
|
+
# Skills still work, and the pointer file never shows up as a skill.
|
|
142
|
+
listing = skills.list_skills(allowed_dir=self.allowed)
|
|
143
|
+
self.assertNotIn(persistence.LAST_SESSION_FILENAME, listing)
|
|
144
|
+
self.assertIn("Skills", open(skills.summary_path(self.allowed)).read())
|
|
145
|
+
|
|
146
|
+
def test_create_skill_after_pointer_written(self):
|
|
147
|
+
cache = os.path.join(self.tmp.name, "cache")
|
|
148
|
+
sdir = os.path.join(cache, "abc")
|
|
149
|
+
os.makedirs(sdir); open(os.path.join(sdir, "history.json"), "w").write("[]")
|
|
150
|
+
persistence.save_last_session_pointer(self.allowed, sdir)
|
|
151
|
+
out = skills.create_skill(self.allowed, "greet", "greets")
|
|
152
|
+
self.assertIn("Created", out)
|
|
153
|
+
self.assertIn("greet", skills.list_skills(self.allowed))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestPrintSessionIdAndDivider(unittest.TestCase):
|
|
157
|
+
"""The interactive run() should show the id as an Assistant turn in brackets,
|
|
158
|
+
and draw a short divider before the user's turn."""
|
|
159
|
+
|
|
160
|
+
def setUp(self):
|
|
161
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
162
|
+
self.addCleanup(self.tmp.cleanup)
|
|
163
|
+
self.buf = io.StringIO()
|
|
164
|
+
self.console = ui.Console(stream=self.buf, color=False, unicode=False)
|
|
165
|
+
|
|
166
|
+
def _fake_llm(self, session):
|
|
167
|
+
def fake(messages, **kw):
|
|
168
|
+
return "hi there", [], {"prompt_tokens": 1, "completion_tokens": 1,
|
|
169
|
+
"total_tokens": 2}
|
|
170
|
+
return fake
|
|
171
|
+
|
|
172
|
+
def test_session_id_renderedin_assistant_turn_and_pointer_written(self):
|
|
173
|
+
allowed = self.tmp.name
|
|
174
|
+
args = _make_args(cache_dir=os.path.join(self.tmp.name, "cache"),
|
|
175
|
+
allowed_dir=allowed, prompt="hello")
|
|
176
|
+
sess = agent_loop.AgentSession(args, console=self.console)
|
|
177
|
+
uid = sess.unique_id
|
|
178
|
+
|
|
179
|
+
with patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
|
|
180
|
+
patch.object(agent_loop.utils, "call_openai_server", self._fake_llm(sess)), \
|
|
181
|
+
patch.object(self.console, "read_line", side_effect=iter(["/quit"])):
|
|
182
|
+
sess.run(initial_prompt="hello")
|
|
183
|
+
|
|
184
|
+
out = self.buf.getvalue()
|
|
185
|
+
# (1) id rendered as an Assistant turn carrying only the id in brackets.
|
|
186
|
+
self.assertIn(f"[{uid}]", out)
|
|
187
|
+
# It sits on the same line as the 'Assistant' role marker.
|
|
188
|
+
self.assertRegex(out, r"(?m)^> Assistant\n \[%s\]$" % uid)
|
|
189
|
+
# No noisy "saved under" line in the session-id turn.
|
|
190
|
+
self.assertNotIn("saved under", out)
|
|
191
|
+
|
|
192
|
+
# (3) a pointer was written under allowed_dir/.mini.
|
|
193
|
+
ptr = os.path.join(allowed, ".mini", persistence.LAST_SESSION_FILENAME)
|
|
194
|
+
self.assertTrue(os.path.isfile(ptr))
|
|
195
|
+
payload = json.load(open(ptr))
|
|
196
|
+
self.assertEqual(payload["session_dir"],
|
|
197
|
+
os.path.abspath(os.path.join(self.tmp.name, "cache", uid)))
|
|
198
|
+
|
|
199
|
+
def test_short_divider_before_preset_you_turn(self):
|
|
200
|
+
args = _make_args(cache_dir=os.path.join(self.tmp.name, "cache"),
|
|
201
|
+
allowed_dir=self.tmp.name, prompt="hello")
|
|
202
|
+
sess = agent_loop.AgentSession(args, console=self.console)
|
|
203
|
+
with patch.object(agent_loop.utils, "determine_vllm_model", return_value=None), \
|
|
204
|
+
patch.object(agent_loop.utils, "call_openai_server", self._fake_llm(sess)), \
|
|
205
|
+
patch.object(self.console, "read_line", side_effect=iter(["/quit"])):
|
|
206
|
+
sess.run(initial_prompt="hello")
|
|
207
|
+
out = self.buf.getvalue()
|
|
208
|
+
# The preset "You" turn is preceded by a short divider line.
|
|
209
|
+
lines = out.splitlines()
|
|
210
|
+
idx = next(i for i, l in enumerate(lines) if l.strip() == "> You")
|
|
211
|
+
divider = lines[idx - 1].strip()
|
|
212
|
+
self.assertTrue(set(divider) <= {"-", " "})
|
|
213
|
+
self.assertIn("-", divider)
|
|
214
|
+
self.assertLess(len(divider.strip("-")), 12)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
unittest.main()
|
|
@@ -115,6 +115,24 @@ class TestConsoleRendering(unittest.TestCase):
|
|
|
115
115
|
self.assertIn("Assistant", text)
|
|
116
116
|
self.assertIn(" line2", text)
|
|
117
117
|
|
|
118
|
+
def test_short_divider_renders_without_ansi(self):
|
|
119
|
+
c = ui.Console(stream=self.buf, color=False)
|
|
120
|
+
c.short_divider()
|
|
121
|
+
out = self.buf.getvalue().rstrip("\n")
|
|
122
|
+
# A short run of dashes, not the full-width divider.
|
|
123
|
+
self.assertTrue(set(out) <= {"-", " "})
|
|
124
|
+
self.assertLess(len(out.strip("-")), 12)
|
|
125
|
+
self.assertIn("-", out)
|
|
126
|
+
|
|
127
|
+
def test_short_divider_shorter_than_full_divider(self):
|
|
128
|
+
c = ui.Console(stream=self.buf, color=False)
|
|
129
|
+
c.short_divider()
|
|
130
|
+
short = len(self.buf.getvalue().rstrip("\n").strip())
|
|
131
|
+
self.buf.seek(0); self.buf.truncate()
|
|
132
|
+
c.divider()
|
|
133
|
+
full = len(self.buf.getvalue().rstrip("\n").strip())
|
|
134
|
+
self.assertLess(short, full)
|
|
135
|
+
|
|
118
136
|
def test_welcome_renders_without_error(self):
|
|
119
137
|
c = ui.Console(stream=self.buf, color=False)
|
|
120
138
|
c.welcome("1.0", "id", "/cache", "/home", "http://x", "model",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|