Skip to content

Commit d51b7b2

Browse files
committed
v0.5.7: Sessions & multi-turn conversations
New features: - internal/session/ package: persists conversation history to ~/.kode/sessions/<id>.json with Create/Load/Append/List/Latest/Delete - kode run --session <task> — single-shot that saves conversation - kode continue [--id <id>] <task> — continue latest or specific session - kode session list — show recent sessions (ID, turns, model, task) - kode session show [id] — full transcript with labeled sections - kode session delete <id> — remove a session Engine changes: - Engine.RunWithMessages(ctx, messages) — resume from pre-built history - Agent.RunWithMessages(ctx, messages) — public API wrapper - runLoop extracted as shared core of Run and RunWithMessages - runLoop returns (answer, updatedMessages, err) for persistence UI improvements: - Renderer.Iteration now accepts turn int parameter (turn > 0 shows 'Turn N') - --session flag on run, --id on continue 11 new tests for session package. All existing tests pass.
1 parent e589eac commit d51b7b2

8 files changed

Lines changed: 814 additions & 23 deletions

File tree

cmd/kode/main.go

Lines changed: 290 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ import (
1414

1515
"github.com/BackendStack21/kode"
1616
"github.com/BackendStack21/kode/internal/config"
17+
"github.com/BackendStack21/kode/internal/llm"
1718
"github.com/BackendStack21/kode/internal/render"
19+
"github.com/BackendStack21/kode/internal/session"
1820
)
1921

2022
// version is set at build time via ldflags: -ldflags "-X main.version=v0.2.1"
@@ -87,6 +89,16 @@ func main() {
8789
fmt.Fprintf(os.Stderr, "kode: %v\n", err)
8890
os.Exit(1)
8991
}
92+
case "continue":
93+
if err := continueCmd(os.Args[2:]); err != nil {
94+
fmt.Fprintf(os.Stderr, "kode: %v\n", err)
95+
os.Exit(1)
96+
}
97+
case "session":
98+
if err := sessionCmd(os.Args[2:]); err != nil {
99+
fmt.Fprintf(os.Stderr, "kode: %v\n", err)
100+
os.Exit(1)
101+
}
90102
default:
91103
fmt.Fprintf(os.Stderr, "kode: unknown command %q\n", os.Args[1])
92104
printUsage()
@@ -114,6 +126,7 @@ type runFlags struct {
114126
Sandbox *bool // nil = not set
115127
NoColor *bool // nil = not set
116128
NoAgents *bool // nil = not set
129+
Session *bool // nil = not set; true = save session after run
117130
Task string
118131

119132
// Sandbox-specific CLI flags
@@ -161,6 +174,9 @@ func parseRunFlags(args []string) (runFlags, error) {
161174
case "--no-agents":
162175
f.NoAgents = boolPtr(true)
163176
i++
177+
case "--session":
178+
f.Session = boolPtr(true)
179+
i++
164180
case "--sandbox-image":
165181
f.SandboxImage = args[i+1]
166182
i += 2
@@ -195,11 +211,17 @@ done:
195211
func printUsage() {
196212
fmt.Println(`Usage:
197213
kode run [flags] <task>
214+
kode run --session [flags] <task>
215+
kode continue [--id <id>] <task>
216+
kode session <list|show [id]|delete <id>>
198217
kode init [--global | -g] [--force | -f]
199218
kode version
200219
201220
Commands:
202221
run Execute a task with the agent loop
222+
run --session Execute and save conversation as a session
223+
continue Continue the most recent session (or by --id)
224+
session Manage sessions: list, show, delete
203225
init Create a config file (default: ./kode.json)
204226
version Print version and exit
205227
@@ -218,6 +240,7 @@ Run flags:
218240
Empty = profile default = provider default.
219241
--no-color Disable colored terminal output
220242
--no-agents Skip loading AGENTS.md from working directory
243+
--session Save conversation as a multi-turn session
221244
--system <prompt> System prompt override
222245
223246
Sandbox flags:
@@ -532,11 +555,38 @@ func run(args []string) error {
532555
defer cancel()
533556

534557
rend.Start(f.Task)
535-
result, err := agent.Run(ctx, f.Task)
536-
if err != nil {
537-
return err
558+
559+
if f.Session != nil && *f.Session {
560+
// Multi-turn session mode: save conversation history
561+
messages := []llm.Message{
562+
{Role: "user", Content: f.Task},
563+
}
564+
if systemMessage != "" {
565+
messages = append([]llm.Message{{Role: "system", Content: systemMessage}}, messages...)
566+
}
567+
568+
result, allMessages, err := agent.RunWithMessages(ctx, messages)
569+
if err != nil {
570+
return err
571+
}
572+
_ = result
573+
574+
store, err := session.NewStore()
575+
if err != nil {
576+
return fmt.Errorf("session store: %w", err)
577+
}
578+
sess, err := store.Create(allMessages, resolved.Model, f.Task)
579+
if err != nil {
580+
return fmt.Errorf("save session: %w", err)
581+
}
582+
fmt.Fprintf(os.Stderr, "kode: session %s saved — continue with: kode continue \"...\"\n", sess.ID)
583+
} else {
584+
// Single-shot mode (default)
585+
_, err := agent.Run(ctx, f.Task)
586+
if err != nil {
587+
return err
588+
}
538589
}
539-
_ = result // rendered by the loop engine via Renderer
540590
return nil
541591
}
542592

@@ -684,3 +734,239 @@ func getVersion() string {
684734
}
685735
return "dev"
686736
}
737+
738+
// ── Continue (Multi-Turn) ─────────────────────────────────────────────
739+
740+
// continueCmd handles `kode continue [--id <id>] <task>`.
741+
// It loads an existing session (latest or by ID), appends the new task,
742+
// runs the agent with full history, and saves the updated session.
743+
func continueCmd(args []string) error {
744+
sessionID := ""
745+
i := 0
746+
for i < len(args)-1 && args[i] == "--id" {
747+
sessionID = args[i+1]
748+
i += 2
749+
}
750+
if i >= len(args) {
751+
return fmt.Errorf("no task provided for continue")
752+
}
753+
task := strings.Join(args[i:], " ")
754+
755+
store, err := session.NewStore()
756+
if err != nil {
757+
return fmt.Errorf("session store: %w", err)
758+
}
759+
760+
var sess *session.Session
761+
if sessionID != "" {
762+
sess, err = store.Load(sessionID)
763+
} else {
764+
sess, err = store.Latest()
765+
}
766+
if err != nil {
767+
return fmt.Errorf("load session: %w", err)
768+
}
769+
770+
fmt.Fprintf(os.Stderr, "kode: continuing session %s (turn %d → %d)\n",
771+
sess.ID, sess.Turns, sess.Turns+1)
772+
773+
// Build tools
774+
tools := builtinTools()
775+
var sandboxCleanup func() error
776+
777+
// Resolve config (no CLI flags for continue — uses session's model)
778+
resolved := config.LoadConfig(config.CLIFlags{Model: sess.Model})
779+
780+
systemMessage := resolved.System
781+
if systemMessage == "" {
782+
systemMessage = defaultSystem
783+
}
784+
785+
// Sandbox (if enabled in config)
786+
if resolved.Sandbox {
787+
sbCfg := sandboxConfig{
788+
Image: resolved.SandboxImage,
789+
Network: resolved.SandboxNetwork,
790+
Readonly: resolved.SandboxReadonly,
791+
Memory: resolved.SandboxMemory,
792+
CPUs: resolved.SandboxCPUs,
793+
User: resolved.SandboxUser,
794+
}
795+
cleanup, err := setupSandbox(tools, sbCfg)
796+
if err != nil {
797+
return fmt.Errorf("sandbox: %w", err)
798+
}
799+
sandboxCleanup = cleanup
800+
}
801+
802+
// Renderer
803+
modelLabel := kode.ProfileLabel(resolved.Model)
804+
if modelLabel == "" {
805+
modelLabel = "deepseek-chat"
806+
}
807+
color := !resolved.NoColor && render.ColorEnabled()
808+
rend := render.New(os.Stderr, color).WithModel(modelLabel)
809+
810+
agent, err := kode.New(kode.Config{
811+
Model: resolved.Model,
812+
BaseURL: resolved.BaseURL,
813+
APIKey: resolved.APIKey,
814+
MaxIterations: resolved.MaxIter,
815+
SystemMessage: systemMessage,
816+
NoProjectFile: resolved.NoAgents,
817+
Thinking: resolved.Thinking,
818+
Tools: tools,
819+
SandboxCleanup: sandboxCleanup,
820+
Renderer: rend,
821+
})
822+
if err != nil {
823+
return err
824+
}
825+
defer agent.Close()
826+
827+
// Build message history: session messages + new user message
828+
// The system message is already in the session
829+
messages := sess.GetMessages()
830+
messages = append(messages, llm.Message{Role: "user", Content: task})
831+
832+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
833+
defer cancel()
834+
835+
rend.Start(task)
836+
result, allMessages, err := agent.RunWithMessages(ctx, messages)
837+
if err != nil {
838+
return err
839+
}
840+
_ = result
841+
842+
// Save updated session
843+
if err := store.Append(sess.ID, allMessages[len(sess.GetMessages()):]); err != nil {
844+
return fmt.Errorf("save session: %w", err)
845+
}
846+
847+
fmt.Fprintf(os.Stderr, "kode: session %s saved (%d turns)\n", sess.ID, sess.Turns+1)
848+
return nil
849+
}
850+
851+
// ── Session Management ────────────────────────────────────────────────
852+
853+
// sessionCmd handles `kode session <list|show|delete> [args]`.
854+
func sessionCmd(args []string) error {
855+
if len(args) == 0 {
856+
fmt.Fprintf(os.Stderr, "Usage: kode session <list|show [id]|delete <id>>\n")
857+
return nil
858+
}
859+
860+
store, err := session.NewStore()
861+
if err != nil {
862+
return fmt.Errorf("session store: %w", err)
863+
}
864+
865+
switch args[0] {
866+
case "list":
867+
return listSessions(store)
868+
case "show":
869+
return showSession(store, args[1:])
870+
case "delete":
871+
return deleteSession(store, args[1:])
872+
default:
873+
return fmt.Errorf("unknown session command %q (use list, show, delete)", args[0])
874+
}
875+
}
876+
877+
func listSessions(store *session.Store) error {
878+
sessions, err := store.List(20)
879+
if err != nil {
880+
return fmt.Errorf("list sessions: %w", err)
881+
}
882+
if len(sessions) == 0 {
883+
fmt.Println("No sessions found.")
884+
return nil
885+
}
886+
887+
fmt.Printf("%-22s %-5s %-30s %s\n", "ID", "Turns", "Model", "Task")
888+
fmt.Println(strings.Repeat("─", 80))
889+
for _, s := range sessions {
890+
task := shorten(s.Task, 30)
891+
model := shorten(s.Model, 20)
892+
fmt.Printf("%-22s %-5d %-30s %s\n", s.ID, s.Turns, model, task)
893+
}
894+
return nil
895+
}
896+
897+
func showSession(store *session.Store, args []string) error {
898+
var id string
899+
if len(args) > 0 {
900+
id = args[0]
901+
} else {
902+
sess, err := store.Latest()
903+
if err != nil {
904+
return fmt.Errorf("no sessions found: %w", err)
905+
}
906+
id = sess.ID
907+
}
908+
909+
sess, err := store.Load(id)
910+
if err != nil {
911+
return fmt.Errorf("load session: %w", err)
912+
}
913+
914+
fmt.Printf("Session: %s\n", sess.ID)
915+
fmt.Printf("Model: %s\n", sess.Model)
916+
fmt.Printf("Turns: %d\n", sess.Turns)
917+
fmt.Printf("Created: %s\n", sess.CreatedAt.Format("2006-01-02 15:04:05 UTC"))
918+
fmt.Printf("Updated: %s\n", sess.UpdatedAt.Format("2006-01-02 15:04:05 UTC"))
919+
fmt.Printf("Task: %s\n", sess.Task)
920+
fmt.Println()
921+
922+
for i, msg := range sess.Messages {
923+
content := strings.TrimSpace(msg.Content)
924+
switch msg.Role {
925+
case "system":
926+
fmt.Printf("── [SYSTEM] ──\n%s\n\n", content)
927+
case "user":
928+
fmt.Printf("── [USER Turn %d] ──\n%s\n\n", countUserTurnsUpTo(sess.Messages, i), content)
929+
case "assistant":
930+
if len(msg.ToolCalls) > 0 {
931+
for _, tc := range msg.ToolCalls {
932+
fmt.Printf("── [TOOL CALL: %s] ──\n%s\n\n", tc.Function.Name, tc.Function.Arguments)
933+
}
934+
} else {
935+
fmt.Printf("── [ASSISTANT] ──\n%s\n\n", content)
936+
}
937+
case "tool":
938+
fmt.Printf("── [TOOL RESULT: %s] ──\n%s\n\n", msg.Name, shorten(content, 200))
939+
}
940+
}
941+
return nil
942+
}
943+
944+
func deleteSession(store *session.Store, args []string) error {
945+
if len(args) == 0 {
946+
return fmt.Errorf("usage: kode session delete <id>")
947+
}
948+
if err := store.Delete(args[0]); err != nil {
949+
return fmt.Errorf("delete session: %w", err)
950+
}
951+
fmt.Printf("Deleted session %s\n", args[0])
952+
return nil
953+
}
954+
955+
// countUserTurnsUpTo counts user messages up to (but not including) index n.
956+
func countUserTurnsUpTo(messages []llm.Message, n int) int {
957+
count := 0
958+
for i := 0; i < n && i < len(messages); i++ {
959+
if messages[i].Role == "user" {
960+
count++
961+
}
962+
}
963+
return count
964+
}
965+
966+
// shorten truncates s to n chars, adding "…" if truncated.
967+
func shorten(s string, n int) string {
968+
if len(s) <= n {
969+
return s
970+
}
971+
return s[:n] + "…"
972+
}

cmd/kode/main_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ func TestPrintUsage(t *testing.T) {
223223

224224
required := []string{
225225
"kode run",
226+
"kode continue",
227+
"kode session",
226228
"kode init",
227229
"kode version",
228230
"Commands:",
@@ -237,6 +239,7 @@ func TestPrintUsage(t *testing.T) {
237239
"--sandbox",
238240
"--no-color",
239241
"--no-agents",
242+
"--session",
240243
"--system",
241244
"--global",
242245
"--force",

0 commit comments

Comments
 (0)