Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c2c75e1
cleanup: remove legacy shell analyzers and AST rollout flags
giveen May 6, 2026
b5250bd
feat: optional session archive compaction (no RAG, no embeddings)
giveen May 6, 2026
e701e6c
fix: context management improvements
giveen May 6, 2026
6d36534
feat: emergency compaction on context window overflow
giveen May 6, 2026
2957d56
feat: rolling-window cycle detection for A→B→A→B loops
giveen May 6, 2026
47c8528
feat: subagents inherit parent session archive
giveen May 6, 2026
3d9adab
fix: four archive compaction correctness fixes
giveen May 6, 2026
634779e
fix: second pass archive compaction correctness
giveen May 6, 2026
44355d6
fix: third pass archive compaction correctness
giveen May 6, 2026
2cd1856
fix: fourth pass archive compaction correctness
giveen May 6, 2026
ce79990
fix: fifth pass archive compaction correctness
giveen May 6, 2026
b0dfb62
fix: sixth pass archive compaction correctness
giveen May 6, 2026
50453d0
tui: hide system-injected archive compaction notices from chat view
giveen May 6, 2026
d4b6f24
fix: seventh pass archive compaction correctness
giveen May 6, 2026
820b868
feat: add session prune subcommand
giveen May 6, 2026
c56a790
fix: ListSessions only returns late-owned sessions (session- prefix)
giveen May 6, 2026
3a23431
perf: skip search index rebuild on no-op compaction; remove dead link…
giveen May 6, 2026
3f2777c
chore: untrack rag_md.txt and add to .gitignore
giveen May 6, 2026
5a746bf
fix: address all copilot reviewer comments on PR #63
giveen May 7, 2026
48f150c
Merge origin/main into rag-doll
giveen May 11, 2026
ecd6efe
style: normalize unix whitelist formatting
giveen May 11, 2026
729c073
Merge remote-tracking branch 'upstream/main' into rag-doll
giveen May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ build/
implementation_plan.md
pr_review_report.md
.late/
rag_md.txt
120 changes: 116 additions & 4 deletions cmd/late/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import (
"strings"
"time"

"late/internal/archive"
"late/internal/assets"
"late/internal/client"
appconfig "late/internal/config"
"late/internal/mcp"
"late/internal/pathutil"
"late/internal/session"
"late/internal/tool"
"late/internal/tui"
"log"

tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
Expand Down Expand Up @@ -52,6 +55,7 @@ func main() {
fmt.Fprintf(os.Stderr, " session list [-v] List all saved sessions (use -v for verbose/detailed view)\n")
fmt.Fprintf(os.Stderr, " session load <id> Load a session by ID\n")
fmt.Fprintf(os.Stderr, " session delete <id> Delete a session by ID\n")
fmt.Fprintf(os.Stderr, " session prune Delete old sessions (--older-than <days>, --keep-last <n>, --dry-run)\n")
fmt.Fprintf(os.Stderr, " worktree list List all worktrees\n")
fmt.Fprintf(os.Stderr, " worktree create <path> [branch] Create a new worktree\n")
fmt.Fprintf(os.Stderr, " worktree remove <path> Remove a worktree\n")
Expand Down Expand Up @@ -136,6 +140,16 @@ func main() {

fmt.Println("Starting late TUI...")

// Redirect log output to a file so it doesn't bleed into the TUI.
if lateDir, logErr := pathutil.LateSessionDir(); logErr == nil {
if mkErr := os.MkdirAll(filepath.Dir(lateDir), 0o700); mkErr == nil {
if lf, lfErr := os.OpenFile(filepath.Join(filepath.Dir(lateDir), "late.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); lfErr == nil {
log.SetOutput(lf)
log.SetFlags(log.LstdFlags)
}
}
}

// Define history path with timestamp-based session ID
sessionsDir, err := session.SessionDir()
if err != nil {
Expand Down Expand Up @@ -226,6 +240,13 @@ func main() {
sess.Registry.Register(t)
}

// Log archive compaction startup status (Phase 7 bootstrap).
if appConfig != nil && appConfig.IsArchiveCompactionEnabled() {
settings := appConfig.ArchiveCompactionSettings()
fmt.Fprintf(os.Stderr, "[late] archive compaction enabled (threshold=%d, keepRecent=%d)\n",
settings.CompactionThresholdMessages, settings.KeepRecentMessages)
}

// Initialize common renderer
renderer, _ := glamour.NewTermRenderer(
glamour.WithStylesFromJSONBytes(tui.LateTheme),
Expand Down Expand Up @@ -295,12 +316,14 @@ func main() {
// Returns: command, args (remaining), verbose flag
func handleSessionCommand(args []string) (string, []string, bool) {
if len(args) == 0 {
fmt.Println("Usage: late session <list|load|delete> [args...]")
fmt.Println("Usage: late session <list|load|delete|prune> [args...]")
fmt.Println("")
fmt.Println("Commands:")
fmt.Println(" list [-v] List all saved sessions (use -v for verbose/detailed view)")
fmt.Println(" load <id> Load a session by ID (can use prefix)")
fmt.Println(" delete <id> Delete a session by ID")
fmt.Println(" list [-v] List all saved sessions (use -v for verbose/detailed view)")
fmt.Println(" load <id> Load a session by ID (can use prefix)")
fmt.Println(" delete <id> Delete a session by ID")
fmt.Println(" prune [--older-than <days>] [--keep-last <n>] [--dry-run]")
fmt.Println(" Delete old sessions by age or count")
return "", nil, false
}

Expand Down Expand Up @@ -345,6 +368,21 @@ func handleSessionCommand(args []string) (string, []string, bool) {
}
handleSessionDelete(commandArgs[0])
return "", nil, true
case "prune":
fs := flag.NewFlagSet("prune", flag.ContinueOnError)
olderThan := fs.Int("older-than", 0, "Delete sessions last updated more than N days ago (0 = disabled)")
keepLast := fs.Int("keep-last", 0, "Keep only the N most recently updated sessions (0 = disabled)")
dryRun := fs.Bool("dry-run", false, "Print what would be deleted without deleting")
if err := fs.Parse(args[1:]); err != nil {
os.Exit(1)
}
if *olderThan == 0 && *keepLast == 0 {
fmt.Println("Error: at least one of --older-than or --keep-last is required")
fmt.Println("Usage: late session prune [--older-than <days>] [--keep-last <n>] [--dry-run]")
os.Exit(1)
}
handleSessionPrune(*olderThan, *keepLast, *dryRun)
return "", nil, true
default:
fmt.Printf("Unknown session command: %s\n", args[0])
handleSessionCommand([]string{})
Expand Down Expand Up @@ -426,9 +464,83 @@ func handleSessionDelete(id string) {
os.Exit(1)
}

// Delete archive and lock files (fail-open: not all sessions have an archive).
if archErr := archive.DeleteFiles(meta.HistoryPath); archErr != nil {
fmt.Fprintf(os.Stderr, "Warning: could not delete archive files: %v\n", archErr)
}

fmt.Printf("Deleted session: %s\n", meta.Title)
}

// handleSessionPrune deletes sessions matching the given criteria.
// olderThan: delete sessions last updated more than this many days ago (0 = disabled).
// keepLast: after age filtering, keep only the N most recent sessions (0 = disabled).
// dryRun: print what would be deleted without removing anything.
func handleSessionPrune(olderThan, keepLast int, dryRun bool) {
metas, err := session.ListSessions() // sorted oldest-first
if err != nil {
fmt.Fprintf(os.Stderr, "Error listing sessions: %v\n", err)
os.Exit(1)
}

// Build candidate set: all sessions that are eligible to be deleted.
// ListSessions returns oldest-first, so we work in that order.
var toDelete []session.SessionMeta
remaining := metas

if olderThan > 0 {
cutoff := time.Now().AddDate(0, 0, -olderThan)
var kept []session.SessionMeta
for _, m := range remaining {
if m.LastUpdated.Before(cutoff) {
toDelete = append(toDelete, m)
} else {
kept = append(kept, m)
}
}
remaining = kept
}

if keepLast > 0 && len(remaining) > keepLast {
// remaining is oldest-first; trim the front (oldest) down to keepLast.
excess := len(remaining) - keepLast
toDelete = append(toDelete, remaining[:excess]...)
remaining = remaining[excess:]
}

if len(toDelete) == 0 {
fmt.Println("No sessions matched the prune criteria.")
return
}

if dryRun {
fmt.Printf("Would delete %d session(s):\n", len(toDelete))
for _, m := range toDelete {
fmt.Printf(" %s %s (last updated %s)\n", m.ID, m.Title, m.LastUpdated.Format("2006-01-02"))
}
return
}

deleted := 0
for _, m := range toDelete {
// Re-use exact same teardown as handleSessionDelete.
sessionsDir, dirErr := session.SessionDir()
if dirErr != nil {
fmt.Fprintf(os.Stderr, "Error getting session directory: %v\n", dirErr)
continue
}
metaPath := filepath.Join(sessionsDir, m.ID+".meta.json")
_ = os.Remove(metaPath)
_ = os.Remove(m.HistoryPath)
if archErr := archive.DeleteFiles(m.HistoryPath); archErr != nil {
fmt.Fprintf(os.Stderr, "Warning: could not delete archive files for %s: %v\n", m.ID, archErr)
}
fmt.Printf("Deleted: %s %s\n", m.ID, m.Title)
deleted++
}
fmt.Printf("Pruned %d session(s).\n", deleted)
}

// handleWorktreeCommand processes worktree subcommands
// Returns: true if a valid command was handled, false otherwise
func handleWorktreeCommand(args []string) bool {
Expand Down
72 changes: 72 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,8 +214,80 @@ late session list # List all saved sessions
late session list -v # Verbose listing with details
late session load <id> # Resume a previous session
late session delete <id> # Delete a session
late session prune --older-than 30 # Delete sessions older than 30 days
late session prune --keep-last 20 # Keep only the 20 most recent sessions
late session prune --older-than 14 --keep-last 10 --dry-run # Preview what would be deleted
```

## Session Archive Compaction

Late can automatically archive older messages when your session grows too long, keeping the active context window lean while preserving full recall via search tools.

### How it works

When the number of messages in the active history exceeds `compaction_threshold_messages`, Late moves the oldest messages (keeping the most recent `keep_recent_messages`) into a compressed archive file stored next to your session history at:

- **Linux/macOS:** `~/.local/share/late/sessions/<session-id>.archive.json`
- **Windows:** `%APPDATA%\late\sessions\<session-id>.archive.json`

The active history file (`<session-id>.json`) shrinks back to just the recent window. The model is notified and can search or retrieve archived messages at any time using the `search_session_archive` and `retrieve_archived_message` tools.

### Enabling compaction

Add an `archive_compaction` block to your `config.json`:

```json
"archive_compaction": {
"enabled": true,
"compaction_threshold_messages": 100,
"keep_recent_messages": 20,
"archive_chunk_size": 50,
"archive_search_max_results": 10
}
```

### Recommended settings by context window size

**64k context window**

```json
"archive_compaction": {
"enabled": true,
"compaction_threshold_messages": 80,
"keep_recent_messages": 20,
"archive_chunk_size": 40,
"archive_search_max_results": 15
}
```

At 64k tokens, compaction fires when the active history reaches 80 messages, keeping the last 20. Each archive chunk covers 40 messages. This leaves enough room for the model to work without running into context limits, while keeping chunk lookup fast.

**128k context window**

```json
"archive_compaction": {
"enabled": true,
"compaction_threshold_messages": 160,
"keep_recent_messages": 30,
"archive_chunk_size": 60,
"archive_search_max_results": 20
}
```

At 128k tokens, you can hold roughly twice as many messages before needing to compact. Keeping 30 recent messages gives the model a wider immediate working window (15 tool call/result pairs). Larger chunks mean fewer archive files over a long session and a 20-result search cap gives broader recall when the model needs to look back.

### Configuration reference

| Key | Default | Description |
|-----|---------|-------------|
| `enabled` | `false` | Must be `true` to activate compaction |
| `compaction_threshold_messages` | `100` | Compact when active history exceeds this many messages |
| `keep_recent_messages` | `20` | Number of most-recent messages to keep in the active window after compaction |
| `archive_chunk_size` | `50` | Messages per archive chunk |
| `archive_search_max_results` | `10` | Max results returned by `search_session_archive` |
| `archive_search_case_sensitive` | `false` | Whether archive search is case-sensitive |
| `archive_compaction_lock_stale_after_seconds` | `300` | How long before a compaction lock is considered stale and cleared |

## Git Worktrees

Late is designed for parallel development. You can manage Git worktrees directly to run separate agent instances in isolated environments:
Expand Down
49 changes: 33 additions & 16 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"late/internal/executor"
"late/internal/orchestrator"
"late/internal/session"
"late/internal/tool"
"late/internal/tui"
"os"
)
Expand All @@ -28,30 +29,37 @@ func NewSubagentOrchestrator(
) (common.Orchestrator, error) {
// 1. Determine System Prompt
systemPrompt := ""
if agentType == "coder" {
switch agentType {
case "coder":
content, err := assets.PromptsFS.ReadFile("prompts/instruction-coding.md")
if err != nil {
return nil, fmt.Errorf("failed to load embedded subagent prompt: %w", err)
}
systemPrompt = string(content)

if injectCWD {
cwd, err := os.Getwd()
if err == nil {
systemPrompt = common.ReplacePlaceholders(systemPrompt, map[string]string{
"${{CWD}}": cwd,
})
}
}

if gemmaThinking {
systemPrompt = "<|think|>" + systemPrompt
case "planner":
content, err := assets.PromptsFS.ReadFile("prompts/instruction-planning.md")
if err != nil {
return nil, fmt.Errorf("failed to load embedded subagent prompt: %w", err)
}
} else {
systemPrompt = string(content)
default:
// TODO: reviewer, committer
return nil, fmt.Errorf("unknown agent type: %s", agentType)
}

if injectCWD {
cwd, err := os.Getwd()
if err == nil {
systemPrompt = common.ReplacePlaceholders(systemPrompt, map[string]string{
"${{CWD}}": cwd,
})
}
}

if gemmaThinking {
systemPrompt = "<|think|>" + systemPrompt
}

// 2. Create Session
// Subagents should not persist their history to the sessions directory
sess := session.New(c, "", []client.ChatMessage{}, systemPrompt, true)
Expand All @@ -68,9 +76,12 @@ func NewSubagentOrchestrator(
}
}

// Always ensure coder subagents have the full toolset (not just planning tools)
if agentType == "coder" {
// Ensure coder subagents have the full toolset; planner gets read-only subset.
switch agentType {
case "coder":
executor.RegisterTools(sess.Registry, enabledTools, false)
case "planner":
executor.RegisterTools(sess.Registry, enabledTools, true)
}

// 3. Construct Initial Context
Expand Down Expand Up @@ -104,6 +115,12 @@ func NewSubagentOrchestrator(

if p, ok := parent.(*orchestrator.BaseOrchestrator); ok {
p.AddChild(child)

// Inherit parent's archive so subagent can search parent session history.
if sub := p.GetArchiveSubsystem(); sub != nil {
maxResults, caseSensitive := p.GetArchiveSearchSettings()
tool.RegisterArchiveTools(sess.Registry, sub, maxResults, caseSensitive)
}
}

return child, nil
Expand Down
Loading