/cont logic

This commit is contained in:
Luxferre
2026-09-01 09:46:13 +03:00
parent 0eb05354df
commit 872414e6fb
3 changed files with 103 additions and 47 deletions
+5 -4
View File
@@ -61,8 +61,9 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
```
Sessions are saved under `~/.bantam/sessions/` and can be managed with these commands:
- `/save` — save the entire conversation to a new session file (auto-id like `20260808-190038`) and generate its summary
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries
- `/save [name]` — save the entire conversation to a session file (named `name`, or defaulting to the MD5 hash of the current project directory) and generate its summary
- `/continue` (alias `/cont`)continue/autoload the session corresponding to the current project directory
- `/list` — list saved sessions (newest first) with their ids, timestamps, message counts and summaries (marking the current project's session)
- `/load <id>` — load a saved session (exact id or unique prefix) and continue from there
- `/compact` — compact context down to the system message and a concise summary using the LLM; the compaction prompt is appended to the conversation to derive the summary, then the conversation is reset to `[system, summary-user-message]` (a fresh prefix, so downstream prompt-cache hits depend on the provider and are not guaranteed)
- `/cfg <param> [val]` — inspect or update a configuration parameter live (writes to `.bantam.cfg`)
@@ -79,7 +80,7 @@ All implementations read `model.cfg` (or `.bantam.cfg`, which takes priority if
Pressing **Ctrl+C** during an active inference run or long-running shell execution cleanly interrupts the turn without appending partial or malformed responses to the conversation context.
The current conversation is also **auto-saved** to `~/.bantam/sessions/autosave.json` after every turn, on `/clear`, `/load`, `/compact`, and on exit — so you can always `/load autosave` to resume where you left off.
The current conversation is also **auto-saved** to `~/.bantam/sessions/<project-md5>.json` after every turn, on `/clear`, `/load`, `/compact`, `/continue`, and on exit — so you can always run `/continue` (or `/cont`) to resume where you left off.
3. File input mode:
```bash
@@ -113,7 +114,7 @@ Using these rules, everyone can build their own copy of Bantam from scratch in l
2. Read model parameters from the config file (`.bantam.cfg` if present, else `model.cfg`) in `key=value` format and discover context window size.
3. Prepare a new message list with the system prompt (`role: "system"`).
4. Read the first command-line parameter. If non-empty, read user prompt from the specified file. If prefixed with `!`, execute the shell command directly via `shell_exec` and exit. Otherwise, append to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, and exit.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If equal to `/save`, write the whole `messages` array to `~/.bantam/sessions/<id>.json` (with an auto-generated summary) and return to step 5. If equal to `/list`, print saved sessions and their summaries and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/autosave.json`.
5. Read user prompt from standard input (with `readline` line editing and history in `~/.bantam_history`; **Ctrl+J** inserts a real newline into the line being edited). If equal to `/quit` or EOF, exit. If equal to `/clear`, reset `messages` to step 3 and return to step 5. If starting with `/save`, write the whole `messages` array to `~/.bantam/sessions/<name>.json` (or `<project-md5>.json` if no name is given) and return to step 5. If equal to `/continue` or `/cont`, load the session corresponding to the current project's MD5 hash and return to step 5. If equal to `/list`, print saved sessions and their summaries (marking current project session) and return to step 5. If starting with `/load`, replace `messages` with the saved session's messages (by exact id or unique prefix) and return to step 5. If equal to `/compact`, ask the LLM to summarize the conversation by appending the compaction prompt to derive the summary, replace `messages` with `[system, summary-user-message]`, and return to step 5. If starting with `/cfg`, display the current value (`/cfg <param>`) or update the config live by writing to `.bantam.cfg` (`/cfg <param> <val>`) and return to step 5. If equal to `/models`, query the `/models` path on the current inference endpoint and print a plain list of supported model IDs (the currently configured model marked with a leading `* `), then return to step 5. If starting with `!`, execute the command directly via `shell_exec` without adding the result to `messages` and return to step 5. If equal to `/help`, print the command list and return to step 5. After every user turn and on exit, auto-save `messages` to `~/.bantam/sessions/<project-md5>.json`.
6. Append user prompt to `messages` (`role: "user"`), run `AL(cfg, messages)`, display token usage, check 60% context threshold for auto-compaction, and go to step 5.
### Agentic loop (`AL(cfg, messages)`) function
+52 -22
View File
@@ -7,6 +7,8 @@ import (
"bufio"
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -1316,14 +1318,25 @@ func fileExists(p string) bool {
return err == nil
}
func saveSession(msgs []Message) (string, string) {
d := sdir()
base := time.Now().Format("20060102-150405")
sid, path := base, filepath.Join(d, base+".json")
for i := 1; fileExists(path); i++ {
sid = fmt.Sprintf("%s-%d", base, i)
path = filepath.Join(d, sid+".json")
func projectID() string {
dir, err := os.Getwd()
if err != nil {
dir = "."
}
if abs, err := filepath.Abs(dir); err == nil {
dir = abs
}
h := md5.Sum([]byte(dir))
return hex.EncodeToString(h[:])
}
func saveSession(msgs []Message, sid string) (string, string) {
sid = strings.TrimSpace(sid)
if sid == "" {
sid = projectID()
}
d := sdir()
path := filepath.Join(d, sid+".json")
s := Session{sid, time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
@@ -1375,15 +1388,7 @@ func loadSession(sid string) ([]Message, error) {
}
func autosave(msgs []Message) {
s := Session{"autosave", time.Now().Format("2006-01-02 15:04:05"), summary(msgs), msgs}
b, err := json.MarshalIndent(s, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "autosave: marshal error: %v\n", err)
return
}
if err := os.WriteFile(filepath.Join(sdir(), "autosave.json"), b, 0644); err != nil {
fmt.Fprintf(os.Stderr, "autosave: write error: %v\n", err)
}
saveSession(msgs, projectID())
}
const compactionPrompt = "You are now acting as a compaction engine. Summarize the preceding conversation concisely but completely, preserving all important facts, decisions, code snippets, tool outputs, errors, and current task state so work can seamlessly continue. Output only the summary."
@@ -1717,19 +1722,32 @@ func main() {
msgs = []Message{{Role: "system", Content: strp(sp)}}
autosave(msgs)
continue
case u == "/save":
sid, sm := saveSession(msgs)
case u == "/save" || strings.HasPrefix(u, "/save "):
name := strings.TrimSpace(strings.TrimPrefix(u, "/save"))
sid, sm := saveSession(msgs, name)
fmt.Println(c("[session saved: "+sid+"]", 32) + " " + c(sm, 2))
continue
case u == "/continue" || u == "/cont":
pid := projectID()
lm, err := loadSession(pid)
if err != nil {
fmt.Println(c("No session found for current project: "+err.Error(), 31))
continue
}
msgs = lm
autosave(msgs)
fmt.Println(c("[session continued: "+pid+"]", 32) + " " + c(summary(msgs), 2))
continue
case u == "/list":
ss := sessions()
if len(ss) == 0 {
fmt.Println(c("No sessions saved yet.", 33))
continue
}
pid := projectID()
for _, s := range ss {
mk := c(" (autosave)", 33)
if s.ID != "autosave" { mk = "" }
mk := ""
if s.ID == pid { mk = c(" (current project)", 33) }
fmt.Println(c(s.ID, 32) + mk + c(fmt.Sprintf(" %s [%d msgs]", s.Created, len(s.Messages)), 2))
fmt.Println(" " + c(s.Summary, 2))
}
@@ -1791,8 +1809,20 @@ func main() {
continue
case u == "/help":
fmt.Println(c("Bantam commands:", 1, 36))
for _, kv := range [][2]string{{"/quit", "exit"}, {"/clear", "reset to system prompt"}, {"/save", "save session"}, {"/list", "list sessions"}, {"/load <id>", "load session"}, {"/compact", "compact context"}, {"/cfg <k> [v]", "get/set config"}, {"!<cmd>", "run shell command directly"}, {"/models", "list models at endpoint"}, {"/help", "show help"}} {
fmt.Println(c(fmt.Sprintf(" %-15s", kv[0]), 1, 32) + kv[1])
for _, kv := range [][2]string{
{"/quit", "exit"},
{"/clear", "reset to system prompt"},
{"/save [name]", "save session (default: project MD5)"},
{"/continue", "continue project session (alias: /cont)"},
{"/list", "list sessions"},
{"/load <id>", "load session"},
{"/compact", "compact context"},
{"/cfg <k> [v]", "get/set config"},
{"!<cmd>", "run shell command directly"},
{"/models", "list models at endpoint"},
{"/help", "show help"},
} {
fmt.Println(c(fmt.Sprintf(" %-18s", kv[0]), 1, 32) + kv[1])
}
continue
}
+46 -21
View File
@@ -604,18 +604,31 @@ func TestFileExists(t *testing.T) {
// ---------- sessions ----------
func TestProjectID(t *testing.T) {
pid := projectID()
if len(pid) != 32 {
t.Fatalf("expected 32-char hex MD5, got %q (len %d)", pid, len(pid))
}
for _, r := range pid {
if !strings.ContainsRune("0123456789abcdef", r) {
t.Fatalf("invalid hex char %c in %q", r, pid)
}
}
}
func TestSaveSessionAndLoad(t *testing.T) {
h := testHome(t)
msgs := []Message{
{Role: "system", Content: strp("sys")},
{Role: "user", Content: strp("hello")},
}
sid, sm := saveSession(msgs)
// Default session ID = project MD5
sid, sm := saveSession(msgs, "")
if sm != "hello" {
t.Errorf("summary = %q, want hello", sm)
}
if sid == "" {
t.Fatalf("empty session id")
if sid != projectID() {
t.Fatalf("expected sid = %s, got %s", projectID(), sid)
}
if !fileExists(filepath.Join(h, ".bantam", "sessions", sid+".json")) {
t.Errorf("session file not written")
@@ -627,17 +640,18 @@ func TestSaveSessionAndLoad(t *testing.T) {
if len(loaded) != 2 || loaded[1].Role != "user" || *loaded[1].Content != "hello" {
t.Errorf("loaded messages mismatch: %+v", loaded)
}
}
func TestSaveSessionCollisionSuffix(t *testing.T) {
testHome(t)
base := time.Now().Format("20060102-150405")
if err := os.WriteFile(filepath.Join(sdir(), base+".json"), []byte("{}"), 0644); err != nil {
t.Fatalf("write: %v", err)
// Custom session ID
sid2, _ := saveSession(msgs, "custom-id")
if sid2 != "custom-id" {
t.Errorf("expected sid2 = custom-id, got %s", sid2)
}
sid, _ := saveSession([]Message{{Role: "user", Content: strp("x")}})
if sid != base+"-1" {
t.Errorf("expected collision suffix %q, got %q", base+"-1", sid)
if !fileExists(filepath.Join(h, ".bantam", "sessions", "custom-id.json")) {
t.Errorf("custom-id.json file not written")
}
loaded2, err := loadSession("custom-id")
if err != nil || len(loaded2) != 2 {
t.Fatalf("loadSession(custom-id) failed: %v", err)
}
}
@@ -690,13 +704,14 @@ func TestAutosave(t *testing.T) {
h := testHome(t)
msgs := []Message{{Role: "user", Content: strp("turn")}}
autosave(msgs)
p := filepath.Join(h, ".bantam", "sessions", "autosave.json")
pid := projectID()
p := filepath.Join(h, ".bantam", "sessions", pid+".json")
if !fileExists(p) {
t.Fatalf("autosave.json not written")
t.Fatalf("%s.json not written", pid)
}
loaded, err := loadSession("autosave")
loaded, err := loadSession(pid)
if err != nil {
t.Fatalf("loadSession(autosave): %v", err)
t.Fatalf("loadSession(%s): %v", pid, err)
}
if len(loaded) != 1 || *loaded[0].Content != "turn" {
t.Errorf("autosave messages mismatch: %+v", loaded)
@@ -1213,17 +1228,27 @@ func TestWriteFile(t *testing.T) {
t.Fatalf("read = %q, want Hello Bantam", string(data))
}
// 5. Offset beyond file length -> padded with null bytes
res, err = writeFile(target, 15, 0, "end")
// 5. Append directly to EOF (offset = len(data), del_bytes 0)
res, err = writeFile(target, len(data), 0, " rocks")
if err != nil {
t.Fatalf("writeFile append: %v", err)
}
data, _ = os.ReadFile(target)
if string(data) != "Hello Bantam rocks" {
t.Fatalf("read = %q, want Hello Bantam rocks", string(data))
}
// 6. Offset beyond file length -> padded with null bytes
res, err = writeFile(target, 25, 0, "end")
if err != nil {
t.Fatalf("writeFile beyond len: %v", err)
}
data, _ = os.ReadFile(target)
if len(data) != 18 || !strings.HasSuffix(string(data), "end") {
t.Fatalf("read length = %d, want 18", len(data))
if len(data) != 28 || !strings.HasSuffix(string(data), "end") {
t.Fatalf("read length = %d, want 28", len(data))
}
// 6. Error on empty path
// 7. Error on empty path
_, err = writeFile("", 0, 0, "abc")
if err == nil {
t.Fatalf("expected error for empty path")