mirror of
https://github.com/mark3labs/kit.git
synced 2026-06-14 03:30:26 +00:00
fd960921ca
- Remove deprecated GenerateWithLoopAndStreaming and TreeManager AppendFantasyMessage / AddFantasyMessages / GetFantasyMessages to close the SDK leakage caused by the kit.TreeManager type alias - Switch extensionAPI method signatures to local Extension* aliases so pkg.go.dev signatures no longer expose internal package names - Bundle runNormalMode dependencies into a runModeDeps struct, shrinking the runNonInteractive and runInteractive call sites from 40+ positional args to (ctx, deps) - Add generic subscribeTyped[E Event] helper and collapse ~30 typed OnXxx wrappers in pkg/kit/events.go onto it (public signatures unchanged) - Extract setupBashPipes / interpretBashExit in internal/core/bash.go to deduplicate the buffered and streaming execution paths - Extract resolveAutoRouteAPIKey and wrapProviderErr helpers in internal/models/providers.go and uniformly apply them across every createXxxProvider site - Reimplement internal/extensions/watcher.go as a thin wrapper over the general-purpose internal/watcher.ContentWatcher, eliminating ~130 LOC of duplicated fsnotify logic while preserving the existing test API - Add ctx.Err() pre-flight checks in executeRead / Write / Edit / Ls so cancellation actually short-circuits pure file-IO tools
1382 lines
36 KiB
Go
1382 lines
36 KiB
Go
package session
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"charm.land/fantasy"
|
|
|
|
"github.com/mark3labs/kit/internal/message"
|
|
)
|
|
|
|
// TreeNode represents a node in the session tree for display purposes.
|
|
|
|
type TreeNode struct {
|
|
Entry any // the underlying entry (*MessageEntry, *ModelChangeEntry, etc.)
|
|
ID string // entry ID
|
|
ParentID string // parent entry ID
|
|
Children []*TreeNode // child nodes
|
|
}
|
|
|
|
// TreeManager manages a tree-structured JSONL session. It is the replacement
|
|
// for the linear session.Manager:
|
|
//
|
|
// - JSONL append-only format (one JSON object per line)
|
|
// - Tree structure via id/parent_id on every entry
|
|
// - Leaf pointer tracking current position
|
|
// - Context building walks from leaf to root
|
|
// - Auto-discovery by working directory
|
|
type TreeManager struct {
|
|
mu sync.RWMutex
|
|
|
|
// header is the session header (first line of the JSONL file).
|
|
header SessionHeader
|
|
|
|
// entries is the ordered list of all entries (excluding header).
|
|
entries []any
|
|
|
|
// index maps entry ID to the entry for O(1) lookup.
|
|
index map[string]any
|
|
|
|
// childIndex maps parent ID to child entry IDs for tree traversal.
|
|
childIndex map[string][]string
|
|
|
|
// labels maps entry ID to user-defined label string.
|
|
labels map[string]string
|
|
|
|
// leafID is the current position in the tree. Empty string means
|
|
// the session is at the root (before any entries).
|
|
leafID string
|
|
|
|
// sessionName is the latest user-defined display name.
|
|
sessionName string
|
|
|
|
// filePath is the JSONL file path. Empty for in-memory sessions.
|
|
filePath string
|
|
|
|
// file is the open file handle for appending entries. Nil for in-memory.
|
|
file *os.File
|
|
|
|
// writer is a buffered writer wrapping file. Writes go through this
|
|
// buffer and are flushed to disk at explicit sync points (after each
|
|
// public Append* call, in Close, etc.) to reduce syscall overhead.
|
|
writer *bufio.Writer
|
|
}
|
|
|
|
// --- Constructors ---
|
|
|
|
// CreateTreeSession creates a new tree session persisted at the default
|
|
// location for the given working directory.
|
|
func CreateTreeSession(cwd string) (*TreeManager, error) {
|
|
sessionDir := DefaultSessionDir(cwd)
|
|
if err := os.MkdirAll(sessionDir, 0755); err != nil {
|
|
return nil, fmt.Errorf("failed to create session directory: %w", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
fileName := fmt.Sprintf("%s_%s.jsonl",
|
|
now.Format("2006-01-02T15-04-05-000Z"),
|
|
GenerateSessionID()[:12],
|
|
)
|
|
filePath := filepath.Join(sessionDir, fileName)
|
|
|
|
header := SessionHeader{
|
|
Type: EntryTypeSession,
|
|
Version: CurrentVersion,
|
|
ID: GenerateSessionID(),
|
|
Timestamp: now,
|
|
Cwd: cwd,
|
|
}
|
|
|
|
tm := &TreeManager{
|
|
header: header,
|
|
entries: make([]any, 0),
|
|
index: make(map[string]any),
|
|
childIndex: make(map[string][]string),
|
|
labels: make(map[string]string),
|
|
filePath: filePath,
|
|
}
|
|
|
|
// Create the file and write the header.
|
|
f, err := os.Create(filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create session file: %w", err)
|
|
}
|
|
tm.file = f
|
|
tm.writer = bufio.NewWriter(f)
|
|
|
|
if err := tm.writeEntry(&header); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("failed to write session header: %w", err)
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("failed to flush session header: %w", err)
|
|
}
|
|
|
|
return tm, nil
|
|
}
|
|
|
|
// ForkToNewSession creates a new session file containing the history up to and
|
|
// including the target entry ID. This matches Pi's /fork behavior: it creates
|
|
// a completely new session file with a parent_session reference, copying all
|
|
// entries from the root to the target point.
|
|
func (tm *TreeManager) ForkToNewSession(cwd string, targetID string) (*TreeManager, error) {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
// Get the branch from root to target (root-to-leaf order).
|
|
branch := tm.getBranchLocked(targetID)
|
|
if len(branch) == 0 {
|
|
return nil, fmt.Errorf("target entry %q not found", targetID)
|
|
}
|
|
|
|
// Create a new session file.
|
|
newTm, err := CreateTreeSession(cwd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Set the parent session reference in the header.
|
|
newTm.header.ParentSession = tm.filePath
|
|
newTm.header.ParentSessionID = tm.header.ID
|
|
|
|
// Rewrite the header with the parent reference.
|
|
// We need to close and recreate the file to rewrite the header.
|
|
if err := newTm.file.Close(); err != nil {
|
|
return nil, fmt.Errorf("failed to close new session file: %w", err)
|
|
}
|
|
|
|
// Recreate the file and write the updated header.
|
|
f, err := os.Create(newTm.filePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to recreate session file: %w", err)
|
|
}
|
|
newTm.file = f
|
|
newTm.writer = bufio.NewWriter(f)
|
|
|
|
if err := newTm.writeEntry(&newTm.header); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("failed to write session header: %w", err)
|
|
}
|
|
|
|
// Copy entries from the branch to the new session.
|
|
// We need to remap IDs since the new session is independent.
|
|
idMap := make(map[string]string) // old ID -> new ID
|
|
var prevNewID string
|
|
|
|
for _, entry := range branch {
|
|
oldID := tm.EntryID(entry)
|
|
newID := GenerateEntryID()
|
|
idMap[oldID] = newID
|
|
|
|
// Create a copy of the entry with the new ID and remapped parent.
|
|
var newEntry any
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
newEntry = &MessageEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeMessage,
|
|
ID: newID,
|
|
ParentID: prevNewID, // Chain sequentially in new session
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
Role: e.Role,
|
|
Parts: e.Parts,
|
|
Model: e.Model,
|
|
Provider: e.Provider,
|
|
}
|
|
// Copy label if present.
|
|
if label, ok := tm.labels[oldID]; ok {
|
|
newTm.labels[newID] = label
|
|
}
|
|
|
|
case *ModelChangeEntry:
|
|
newEntry = &ModelChangeEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeModelChange,
|
|
ID: newID,
|
|
ParentID: prevNewID,
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
Provider: e.Provider,
|
|
ModelID: e.ModelID,
|
|
}
|
|
|
|
case *LabelEntry:
|
|
// Remap the target ID if it's in our copied branch.
|
|
newTargetID := e.TargetID
|
|
if mapped, ok := idMap[e.TargetID]; ok {
|
|
newTargetID = mapped
|
|
}
|
|
newEntry = &LabelEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeLabel,
|
|
ID: newID,
|
|
ParentID: prevNewID,
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
TargetID: newTargetID,
|
|
Label: e.Label,
|
|
}
|
|
|
|
case *SessionInfoEntry:
|
|
newEntry = &SessionInfoEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeSessionInfo,
|
|
ID: newID,
|
|
ParentID: prevNewID,
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
Name: e.Name,
|
|
}
|
|
newTm.sessionName = e.Name
|
|
|
|
case *ExtensionDataEntry:
|
|
newEntry = &ExtensionDataEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeExtensionData,
|
|
ID: newID,
|
|
ParentID: prevNewID,
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
ExtType: e.ExtType,
|
|
Data: e.Data,
|
|
}
|
|
|
|
case *BranchSummaryEntry:
|
|
// Remap the from ID if it's in our copied branch.
|
|
newFromID := e.FromID
|
|
if mapped, ok := idMap[e.FromID]; ok {
|
|
newFromID = mapped
|
|
}
|
|
newEntry = &BranchSummaryEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeBranchSummary,
|
|
ID: newID,
|
|
ParentID: prevNewID,
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
FromID: newFromID,
|
|
Summary: e.Summary,
|
|
}
|
|
|
|
case *CompactionEntry:
|
|
// Remap the first kept entry ID if it's in our copied branch.
|
|
newFirstKeptID := e.FirstKeptEntryID
|
|
if mapped, ok := idMap[e.FirstKeptEntryID]; ok {
|
|
newFirstKeptID = mapped
|
|
}
|
|
newEntry = &CompactionEntry{
|
|
Entry: Entry{
|
|
Type: EntryTypeCompaction,
|
|
ID: newID,
|
|
ParentID: prevNewID,
|
|
Timestamp: e.Timestamp,
|
|
},
|
|
Summary: e.Summary,
|
|
FirstKeptEntryID: newFirstKeptID,
|
|
TokensBefore: e.TokensBefore,
|
|
TokensAfter: e.TokensAfter,
|
|
MessagesRemoved: e.MessagesRemoved,
|
|
ReadFiles: e.ReadFiles,
|
|
ModifiedFiles: e.ModifiedFiles,
|
|
}
|
|
}
|
|
|
|
if newEntry != nil {
|
|
if err := newTm.appendAndPersist(newEntry); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("failed to copy entry: %w", err)
|
|
}
|
|
prevNewID = newID
|
|
}
|
|
}
|
|
|
|
// Flush all buffered writes from the fork in a single syscall.
|
|
if err := newTm.flushLocked(); err != nil {
|
|
_ = f.Close()
|
|
return nil, fmt.Errorf("failed to flush forked session: %w", err)
|
|
}
|
|
|
|
// Set the leaf to the last entry in the new session.
|
|
newTm.leafID = prevNewID
|
|
|
|
return newTm, nil
|
|
}
|
|
|
|
// OpenTreeSession opens an existing JSONL session file.
|
|
func OpenTreeSession(path string) (*TreeManager, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read session file: %w", err)
|
|
}
|
|
|
|
tm := &TreeManager{
|
|
entries: make([]any, 0),
|
|
index: make(map[string]any),
|
|
childIndex: make(map[string][]string),
|
|
labels: make(map[string]string),
|
|
filePath: path,
|
|
}
|
|
|
|
reader := bufio.NewReader(strings.NewReader(string(data)))
|
|
lineNum := 0
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil {
|
|
if err == io.EOF {
|
|
// Process the last line if it's not empty
|
|
if strings.TrimSpace(line) != "" {
|
|
lineNum++
|
|
entry, err := UnmarshalEntry([]byte(line))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("line %d: %w", lineNum, err)
|
|
}
|
|
if lineNum == 1 {
|
|
h, ok := entry.(*SessionHeader)
|
|
if !ok {
|
|
return nil, fmt.Errorf("first line must be a session header, got %T", entry)
|
|
}
|
|
tm.header = *h
|
|
} else {
|
|
tm.addEntryToIndex(entry)
|
|
}
|
|
}
|
|
break
|
|
}
|
|
return nil, fmt.Errorf("failed to read session file: %w", err)
|
|
}
|
|
|
|
if strings.TrimSpace(line) == "" {
|
|
continue
|
|
}
|
|
lineNum++
|
|
|
|
entry, err := UnmarshalEntry([]byte(line))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("line %d: %w", lineNum, err)
|
|
}
|
|
|
|
if lineNum == 1 {
|
|
h, ok := entry.(*SessionHeader)
|
|
if !ok {
|
|
return nil, fmt.Errorf("first line must be a session header, got %T", entry)
|
|
}
|
|
tm.header = *h
|
|
continue
|
|
}
|
|
|
|
tm.addEntryToIndex(entry)
|
|
}
|
|
|
|
// Set leaf to the last entry.
|
|
if len(tm.entries) > 0 {
|
|
tm.leafID = tm.EntryID(tm.entries[len(tm.entries)-1])
|
|
}
|
|
|
|
// Validate tree integrity and log diagnostics
|
|
tm.LogTreeDiagnostics()
|
|
|
|
// Open file for appending.
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to open session file for append: %w", err)
|
|
}
|
|
tm.file = f
|
|
tm.writer = bufio.NewWriter(f)
|
|
|
|
return tm, nil
|
|
}
|
|
|
|
// ContinueRecent finds the most recently modified session for the given cwd,
|
|
// or creates a new one if none exists.
|
|
func ContinueRecent(cwd string) (*TreeManager, error) {
|
|
sessions, err := ListSessions(cwd)
|
|
if err != nil || len(sessions) == 0 {
|
|
return CreateTreeSession(cwd)
|
|
}
|
|
// sessions are sorted by modified time (newest first).
|
|
return OpenTreeSession(sessions[0].Path)
|
|
}
|
|
|
|
// InMemoryTreeSession creates a tree session that is not persisted to disk.
|
|
func InMemoryTreeSession(cwd string) *TreeManager {
|
|
return &TreeManager{
|
|
header: SessionHeader{
|
|
Type: EntryTypeSession,
|
|
Version: CurrentVersion,
|
|
ID: GenerateSessionID(),
|
|
Timestamp: time.Now().UTC(),
|
|
Cwd: cwd,
|
|
},
|
|
entries: make([]any, 0),
|
|
index: make(map[string]any),
|
|
childIndex: make(map[string][]string),
|
|
labels: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// --- Append operations (all return entry ID) ---
|
|
|
|
// AppendMessage adds a message entry to the tree and persists it.
|
|
func (tm *TreeManager) AppendMessage(msg message.Message) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
// Validate parent chain before appending to detect/prevent cycles
|
|
// that could be caused by external file corruption or race conditions.
|
|
if err := tm.validateParentChainLocked(tm.leafID, ""); err != nil {
|
|
return "", fmt.Errorf("parent chain validation failed: %w", err)
|
|
}
|
|
|
|
entry, err := NewMessageEntry(tm.leafID, msg)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush message: %w", err)
|
|
}
|
|
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// AppendLLMMessage converts an LLM message and appends it.
|
|
func (tm *TreeManager) AppendLLMMessage(msg fantasy.Message) (string, error) {
|
|
return tm.AppendMessage(message.FromLLMMessage(msg))
|
|
}
|
|
|
|
// AppendModelChange records a model/provider change.
|
|
func (tm *TreeManager) AppendModelChange(provider, modelID string) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
entry := NewModelChangeEntry(tm.leafID, provider, modelID)
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush model change: %w", err)
|
|
}
|
|
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// AppendBranchSummary adds a summary of an abandoned branch.
|
|
func (tm *TreeManager) AppendBranchSummary(fromID, summary string) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
entry := NewBranchSummaryEntry(tm.leafID, fromID, summary)
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush branch summary: %w", err)
|
|
}
|
|
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// AppendLabel sets a label on a target entry.
|
|
func (tm *TreeManager) AppendLabel(targetID, label string) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
entry := NewLabelEntry(tm.leafID, targetID, label)
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush label: %w", err)
|
|
}
|
|
|
|
tm.labels[targetID] = label
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// AppendSessionInfo sets a display name for the session.
|
|
func (tm *TreeManager) AppendSessionInfo(name string) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
entry := NewSessionInfoEntry(tm.leafID, name)
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush session info: %w", err)
|
|
}
|
|
|
|
tm.sessionName = name
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// AppendExtensionData adds an extension data entry to the tree and persists it.
|
|
// Extensions use this to store custom state that survives across session restarts.
|
|
func (tm *TreeManager) AppendExtensionData(extType, data string) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
entry := NewExtensionDataEntry(tm.leafID, extType, data)
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush extension data: %w", err)
|
|
}
|
|
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// AppendCompaction adds a compaction entry to the tree. The entry records
|
|
// the summary and the ID of the first entry that should be preserved in the
|
|
// LLM context. Messages before that entry are replaced by the summary.
|
|
//
|
|
// The compaction entry becomes a new "root" for the post-compaction branch
|
|
// with no parent (empty ParentID). This breaks the parent chain so that old
|
|
// compacted messages are no longer traversed when building context. The kept
|
|
// messages are explicitly collected via FirstKeptEntryID in BuildContext.
|
|
func (tm *TreeManager) AppendCompaction(summary, firstKeptEntryID string, tokensBefore, tokensAfter, messagesRemoved int, readFiles, modifiedFiles []string) (string, error) {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
// Validate that firstKeptEntryID exists if provided
|
|
if firstKeptEntryID != "" {
|
|
if _, ok := tm.index[firstKeptEntryID]; !ok {
|
|
return "", fmt.Errorf("first kept entry %q does not exist", firstKeptEntryID)
|
|
}
|
|
}
|
|
|
|
// The compaction entry has no parent, making it a new "root" for the
|
|
// post-compaction branch. This ensures old compacted messages are not
|
|
// traversed when walking from the current leaf.
|
|
entry := NewCompactionEntry("", summary, firstKeptEntryID, tokensBefore, tokensAfter, messagesRemoved, readFiles, modifiedFiles)
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return "", err
|
|
}
|
|
if err := tm.flushLocked(); err != nil {
|
|
return "", fmt.Errorf("failed to flush compaction: %w", err)
|
|
}
|
|
|
|
tm.leafID = entry.ID
|
|
return entry.ID, nil
|
|
}
|
|
|
|
// GetExtensionData returns all extension data entries matching the given type,
|
|
// walking the current branch from root to leaf. If extType is empty, all
|
|
// extension data entries on the branch are returned.
|
|
func (tm *TreeManager) GetExtensionData(extType string) []*ExtensionDataEntry {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
if tm.leafID == "" {
|
|
return nil
|
|
}
|
|
|
|
branch := tm.getBranchLocked(tm.leafID)
|
|
var results []*ExtensionDataEntry
|
|
for _, entry := range branch {
|
|
if e, ok := entry.(*ExtensionDataEntry); ok {
|
|
if extType == "" || e.ExtType == extType {
|
|
results = append(results, e)
|
|
}
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
// --- Tree navigation ---
|
|
|
|
// Branch moves the leaf pointer to the given entry ID, creating a branch
|
|
// point. Subsequent appends will extend from this new position.
|
|
func (tm *TreeManager) Branch(entryID string) error {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
if entryID == "" {
|
|
tm.leafID = ""
|
|
return nil
|
|
}
|
|
|
|
if _, ok := tm.index[entryID]; !ok {
|
|
return fmt.Errorf("entry %q not found", entryID)
|
|
}
|
|
tm.leafID = entryID
|
|
return nil
|
|
}
|
|
|
|
// ResetLeaf moves the leaf pointer to before the first entry (empty conversation).
|
|
func (tm *TreeManager) ResetLeaf() {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
tm.leafID = ""
|
|
}
|
|
|
|
// GetLeafID returns the current leaf position.
|
|
func (tm *TreeManager) GetLeafID() string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.leafID
|
|
}
|
|
|
|
// GetEntry returns the entry with the given ID, or nil if not found.
|
|
func (tm *TreeManager) GetEntry(id string) any {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.index[id]
|
|
}
|
|
|
|
// GetEntries returns all entries (excluding the session header).
|
|
func (tm *TreeManager) GetEntries() []any {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
cp := make([]any, len(tm.entries))
|
|
copy(cp, tm.entries)
|
|
return cp
|
|
}
|
|
|
|
// GetChildren returns direct child entry IDs for a given parent.
|
|
func (tm *TreeManager) GetChildren(parentID string) []string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
cp := make([]string, len(tm.childIndex[parentID]))
|
|
copy(cp, tm.childIndex[parentID])
|
|
return cp
|
|
}
|
|
|
|
// GetBranch returns the path of entries from the given entry to the root,
|
|
// ordered from root to the entry. If fromID is empty, uses the current leaf.
|
|
func (tm *TreeManager) GetBranch(fromID string) []any {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
if fromID == "" {
|
|
fromID = tm.leafID
|
|
}
|
|
if fromID == "" {
|
|
return nil
|
|
}
|
|
|
|
var path []any
|
|
current := fromID
|
|
for current != "" {
|
|
entry, ok := tm.index[current]
|
|
if !ok {
|
|
break
|
|
}
|
|
path = append(path, entry)
|
|
current = tm.entryParentID(entry)
|
|
}
|
|
|
|
// Reverse to get root-to-leaf order.
|
|
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
|
path[i], path[j] = path[j], path[i]
|
|
}
|
|
return path
|
|
}
|
|
|
|
// GetLabel returns the label for an entry, or empty string if none.
|
|
func (tm *TreeManager) GetLabel(id string) string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.labels[id]
|
|
}
|
|
|
|
// GetTree builds the full tree structure from root entries.
|
|
func (tm *TreeManager) GetTree() []*TreeNode {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
// Find root entries (entries with empty ParentID).
|
|
var roots []*TreeNode
|
|
rootIDs := tm.childIndex[""]
|
|
|
|
for _, id := range rootIDs {
|
|
node := tm.buildTreeNode(id)
|
|
if node != nil {
|
|
roots = append(roots, node)
|
|
}
|
|
}
|
|
return roots
|
|
}
|
|
|
|
// --- Context building ---
|
|
|
|
// BuildContext walks from the current leaf to the root and returns the
|
|
// conversation messages suitable for sending to the LLM. Compaction entries
|
|
// cause older messages to be replaced by the summary. Branch summaries are
|
|
// converted to user messages to provide context from abandoned branches.
|
|
// Also returns the latest model/provider settings encountered on the path.
|
|
func (tm *TreeManager) BuildContext() (messages []fantasy.Message, provider string, modelID string) {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
if tm.leafID == "" {
|
|
return nil, "", ""
|
|
}
|
|
|
|
// Walk from leaf to root collecting entries.
|
|
branch := tm.getBranchLocked(tm.leafID)
|
|
|
|
// Find the last compaction entry on this branch — it determines
|
|
// which older messages are replaced by the summary.
|
|
var lastCompaction *CompactionEntry
|
|
var compactionIndex = -1
|
|
for i := len(branch) - 1; i >= 0; i-- {
|
|
if c, ok := branch[i].(*CompactionEntry); ok {
|
|
lastCompaction = c
|
|
compactionIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
// If there is a compaction, inject the summary first, then the
|
|
// preserved "kept" messages (chronologically before the compaction),
|
|
// then the post-compaction messages (chronologically after).
|
|
//
|
|
// Order matters: the kept messages must come BEFORE the post-compaction
|
|
// branch so the LLM sees the conversation in chronological order. If the
|
|
// kept messages were appended last, the latest user message in the
|
|
// current branch would be followed by an older kept user message,
|
|
// breaking the strict user/assistant alternation that providers expect
|
|
// and causing the model to respond as if the previous turn never
|
|
// happened.
|
|
if lastCompaction != nil {
|
|
messages = append(messages, fantasy.Message{
|
|
Role: fantasy.MessageRoleSystem,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{
|
|
Text: fmt.Sprintf("[Conversation summary — earlier messages were compacted]\n\n%s", lastCompaction.Summary),
|
|
},
|
|
},
|
|
})
|
|
|
|
// Step 1: collect the kept messages starting from FirstKeptEntryID.
|
|
// These are not on the current branch (the compaction entry is a
|
|
// new root with no parent), so we iterate tm.entries in append order
|
|
// and stop when we reach the compaction entry itself.
|
|
if lastCompaction.FirstKeptEntryID != "" {
|
|
found := false
|
|
for _, entry := range tm.entries {
|
|
entryID := tm.EntryID(entry)
|
|
|
|
// Skip entries until we reach the first kept entry.
|
|
if !found {
|
|
if entryID == lastCompaction.FirstKeptEntryID {
|
|
found = true
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Stop when we reach the compaction entry itself; messages
|
|
// after it are collected from the branch walk below.
|
|
if entryID == lastCompaction.ID {
|
|
break
|
|
}
|
|
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
msg, err := e.ToMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
msgs := msg.ToLLMMessages()
|
|
messages = append(messages, msgs...)
|
|
|
|
case *BranchSummaryEntry:
|
|
if e.Summary != "" {
|
|
messages = append(messages, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{
|
|
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
case *ModelChangeEntry:
|
|
provider = e.Provider
|
|
modelID = e.ModelID
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: collect entries on the current branch after the compaction
|
|
// entry (these are post-compaction messages). The compaction entry
|
|
// itself is skipped — its summary was already injected above.
|
|
for i := compactionIndex; i < len(branch); i++ {
|
|
entry := branch[i]
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
msg, err := e.ToMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
msgs := msg.ToLLMMessages()
|
|
messages = append(messages, msgs...)
|
|
|
|
case *BranchSummaryEntry:
|
|
if e.Summary != "" {
|
|
messages = append(messages, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{
|
|
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
case *ModelChangeEntry:
|
|
provider = e.Provider
|
|
modelID = e.ModelID
|
|
|
|
case *CompactionEntry:
|
|
// Summary already injected above.
|
|
continue
|
|
}
|
|
}
|
|
|
|
return messages, provider, modelID
|
|
}
|
|
|
|
// No compaction - process the entire branch normally.
|
|
for _, entry := range branch {
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
msg, err := e.ToMessage()
|
|
if err != nil {
|
|
continue // skip malformed entries
|
|
}
|
|
msgs := msg.ToLLMMessages()
|
|
messages = append(messages, msgs...)
|
|
|
|
case *BranchSummaryEntry:
|
|
// Convert branch summary to a user message for context.
|
|
if e.Summary != "" {
|
|
messages = append(messages, fantasy.Message{
|
|
Role: fantasy.MessageRoleUser,
|
|
Content: []fantasy.MessagePart{
|
|
fantasy.TextPart{
|
|
Text: fmt.Sprintf("[Branch context: %s]", e.Summary),
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
case *ModelChangeEntry:
|
|
provider = e.Provider
|
|
modelID = e.ModelID
|
|
}
|
|
}
|
|
|
|
return messages, provider, modelID
|
|
}
|
|
|
|
// --- Session info ---
|
|
|
|
// GetSessionID returns the session UUID.
|
|
func (tm *TreeManager) GetSessionID() string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.header.ID
|
|
}
|
|
|
|
// GetSessionName returns the user-defined display name, or empty string.
|
|
func (tm *TreeManager) GetSessionName() string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.sessionName
|
|
}
|
|
|
|
// GetCwd returns the working directory this session was created in.
|
|
func (tm *TreeManager) GetCwd() string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.header.Cwd
|
|
}
|
|
|
|
// GetFilePath returns the JSONL file path, or empty for in-memory sessions.
|
|
func (tm *TreeManager) GetFilePath() string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.filePath
|
|
}
|
|
|
|
// GetHeader returns a copy of the session header.
|
|
func (tm *TreeManager) GetHeader() SessionHeader {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.header
|
|
}
|
|
|
|
// IsPersisted returns true if this session writes to disk.
|
|
func (tm *TreeManager) IsPersisted() bool {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return tm.filePath != ""
|
|
}
|
|
|
|
// EntryCount returns the number of entries (excluding header).
|
|
func (tm *TreeManager) EntryCount() int {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
return len(tm.entries)
|
|
}
|
|
|
|
// MessageCount returns the number of message entries.
|
|
func (tm *TreeManager) MessageCount() int {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
count := 0
|
|
for _, e := range tm.entries {
|
|
if _, ok := e.(*MessageEntry); ok {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
// IsEmpty returns true if the session has no messages (only header).
|
|
func (tm *TreeManager) IsEmpty() bool {
|
|
return tm.MessageCount() == 0
|
|
}
|
|
|
|
// Flush writes any buffered data to the underlying file.
|
|
func (tm *TreeManager) Flush() error {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
return tm.flushLocked()
|
|
}
|
|
|
|
// flushLocked writes buffered data to disk. Caller must hold the lock.
|
|
func (tm *TreeManager) flushLocked() error {
|
|
if tm.writer != nil {
|
|
return tm.writer.Flush()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Close flushes any buffered writes and closes the underlying file handle.
|
|
func (tm *TreeManager) Close() error {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
if tm.file != nil {
|
|
// Flush buffered data before closing.
|
|
if tm.writer != nil {
|
|
_ = tm.writer.Flush()
|
|
tm.writer = nil
|
|
}
|
|
err := tm.file.Close()
|
|
tm.file = nil
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetContextEntryIDs returns the entry IDs corresponding to the fantasy
|
|
// messages returned by BuildContext, in the same order. Each entry ID maps
|
|
// to the session entry that produced the fantasy message at the same index.
|
|
// This is used by compaction to map a cut point index back to an entry ID.
|
|
//
|
|
// Note: A single MessageEntry produces at most one fantasy message. Branch
|
|
// summary entries also produce one message each. The returned slice has the
|
|
// same length as the messages slice from BuildContext (excluding the
|
|
// compaction summary system message, which has no entry ID — it gets the
|
|
// empty string "").
|
|
func (tm *TreeManager) GetContextEntryIDs() []string {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
if tm.leafID == "" {
|
|
return nil
|
|
}
|
|
|
|
branch := tm.getBranchLocked(tm.leafID)
|
|
|
|
// Find the last compaction entry for skip logic.
|
|
var lastCompaction *CompactionEntry
|
|
var compactionIndex = -1
|
|
for i := len(branch) - 1; i >= 0; i-- {
|
|
if c, ok := branch[i].(*CompactionEntry); ok {
|
|
lastCompaction = c
|
|
compactionIndex = i
|
|
break
|
|
}
|
|
}
|
|
|
|
var ids []string
|
|
|
|
// If there's a compaction, we collect IDs in the same order as
|
|
// BuildContext: [summary placeholder, kept messages, post-compaction
|
|
// messages]. This ordering must stay in sync with BuildContext so a
|
|
// cut-point index can be mapped back to the correct entry ID.
|
|
if lastCompaction != nil {
|
|
// Placeholder for the summary system message (no entry ID).
|
|
ids = append(ids, "")
|
|
|
|
// Step 1: IDs of the kept messages starting at FirstKeptEntryID.
|
|
// Iterate tm.entries in append order and stop at the compaction
|
|
// entry to avoid double-counting post-compaction messages.
|
|
if lastCompaction.FirstKeptEntryID != "" {
|
|
found := false
|
|
for _, entry := range tm.entries {
|
|
entryID := tm.EntryID(entry)
|
|
|
|
if !found {
|
|
if entryID == lastCompaction.FirstKeptEntryID {
|
|
found = true
|
|
} else {
|
|
continue
|
|
}
|
|
}
|
|
|
|
if entryID == lastCompaction.ID {
|
|
break
|
|
}
|
|
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
msg, err := e.ToMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
msgs := msg.ToLLMMessages()
|
|
for range msgs {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
|
|
case *BranchSummaryEntry:
|
|
if e.Summary != "" {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: IDs of entries after the compaction entry on the current
|
|
// branch (post-compaction messages).
|
|
for i := compactionIndex + 1; i < len(branch); i++ {
|
|
entry := branch[i]
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
msg, err := e.ToMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
msgs := msg.ToLLMMessages()
|
|
for range msgs {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
|
|
case *BranchSummaryEntry:
|
|
if e.Summary != "" {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
// No compaction - collect IDs from the entire branch.
|
|
for _, entry := range branch {
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
msg, err := e.ToMessage()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
msgs := msg.ToLLMMessages()
|
|
for range msgs {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
|
|
case *BranchSummaryEntry:
|
|
if e.Summary != "" {
|
|
ids = append(ids, e.ID)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ids
|
|
}
|
|
|
|
// GetLastCompaction returns the most recent CompactionEntry on the current
|
|
// branch, or nil if none exists. Used to carry forward file tracking.
|
|
func (tm *TreeManager) GetLastCompaction() *CompactionEntry {
|
|
tm.mu.RLock()
|
|
defer tm.mu.RUnlock()
|
|
|
|
if tm.leafID == "" {
|
|
return nil
|
|
}
|
|
|
|
branch := tm.getBranchLocked(tm.leafID)
|
|
for i := len(branch) - 1; i >= 0; i-- {
|
|
if c, ok := branch[i].(*CompactionEntry); ok {
|
|
return c
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Legacy bridge ---
|
|
|
|
// AddLLMMessages appends multiple LLM messages as entries. This is
|
|
// used when syncing from the agent's ConversationMessages after a step.
|
|
// All entries are buffered and flushed to disk in a single batch.
|
|
func (tm *TreeManager) AddLLMMessages(msgs []fantasy.Message) error {
|
|
tm.mu.Lock()
|
|
defer tm.mu.Unlock()
|
|
|
|
for _, msg := range msgs {
|
|
entry, err := NewMessageEntry(tm.leafID, message.FromLLMMessage(msg))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tm.appendAndPersist(entry); err != nil {
|
|
return err
|
|
}
|
|
tm.leafID = entry.ID
|
|
}
|
|
return tm.flushLocked()
|
|
}
|
|
|
|
// GetLLMMessages builds the context and returns just the messages.
|
|
// This satisfies the same conceptual role as the old Manager.GetMessages().
|
|
func (tm *TreeManager) GetLLMMessages() []fantasy.Message {
|
|
msgs, _, _ := tm.BuildContext()
|
|
return msgs
|
|
}
|
|
|
|
// --- Internal helpers ---
|
|
|
|
// addEntryToIndex adds an entry to the in-memory indices.
|
|
func (tm *TreeManager) addEntryToIndex(entry any) {
|
|
tm.entries = append(tm.entries, entry)
|
|
|
|
id := tm.EntryID(entry)
|
|
parentID := tm.entryParentID(entry)
|
|
|
|
if id != "" {
|
|
tm.index[id] = entry
|
|
tm.childIndex[parentID] = append(tm.childIndex[parentID], id)
|
|
}
|
|
|
|
// Track labels and session names.
|
|
switch e := entry.(type) {
|
|
case *LabelEntry:
|
|
tm.labels[e.TargetID] = e.Label
|
|
case *SessionInfoEntry:
|
|
tm.sessionName = e.Name
|
|
}
|
|
}
|
|
|
|
// appendAndPersist adds an entry to indices and writes it to the JSONL file.
|
|
func (tm *TreeManager) appendAndPersist(entry any) error {
|
|
tm.addEntryToIndex(entry)
|
|
if tm.file != nil {
|
|
return tm.writeEntry(entry)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeEntry serializes an entry and appends it to the buffered writer.
|
|
// The data is not flushed to disk until flushLocked is called.
|
|
func (tm *TreeManager) writeEntry(entry any) error {
|
|
data, err := json.Marshal(entry)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal entry: %w", err)
|
|
}
|
|
if tm.writer != nil {
|
|
if _, err := tm.writer.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return tm.writer.WriteByte('\n')
|
|
}
|
|
// Fallback for direct file writes (shouldn't happen in normal flow).
|
|
data = append(data, '\n')
|
|
_, err = tm.file.Write(data)
|
|
return err
|
|
}
|
|
|
|
// EntryID extracts the ID from any entry type.
|
|
func (tm *TreeManager) EntryID(entry any) string {
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
return e.ID
|
|
case *ModelChangeEntry:
|
|
return e.ID
|
|
case *BranchSummaryEntry:
|
|
return e.ID
|
|
case *LabelEntry:
|
|
return e.ID
|
|
case *SessionInfoEntry:
|
|
return e.ID
|
|
case *ExtensionDataEntry:
|
|
return e.ID
|
|
case *CompactionEntry:
|
|
return e.ID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// entryParentID extracts the ParentID from any entry type.
|
|
func (tm *TreeManager) entryParentID(entry any) string {
|
|
switch e := entry.(type) {
|
|
case *MessageEntry:
|
|
return e.ParentID
|
|
case *ModelChangeEntry:
|
|
return e.ParentID
|
|
case *BranchSummaryEntry:
|
|
return e.ParentID
|
|
case *LabelEntry:
|
|
return e.ParentID
|
|
case *SessionInfoEntry:
|
|
return e.ParentID
|
|
case *ExtensionDataEntry:
|
|
return e.ParentID
|
|
case *CompactionEntry:
|
|
return e.ParentID
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// getBranchLocked walks from an entry to the root (must hold at least RLock).
|
|
func (tm *TreeManager) getBranchLocked(fromID string) []any {
|
|
var path []any
|
|
visited := make(map[string]bool) // prevent cycles
|
|
current := fromID
|
|
for current != "" {
|
|
if visited[current] {
|
|
break
|
|
}
|
|
visited[current] = true
|
|
entry, ok := tm.index[current]
|
|
if !ok {
|
|
break
|
|
}
|
|
path = append(path, entry)
|
|
current = tm.entryParentID(entry)
|
|
}
|
|
|
|
// Reverse to root-first order.
|
|
for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 {
|
|
path[i], path[j] = path[j], path[i]
|
|
}
|
|
return path
|
|
}
|
|
|
|
// buildTreeNode recursively builds a TreeNode from an entry ID.
|
|
// It includes a depth limit to prevent infinite recursion in case of
|
|
// corrupted parent-child relationships.
|
|
func (tm *TreeManager) buildTreeNode(id string) *TreeNode {
|
|
return tm.buildTreeNodeDepth(id, 0, make(map[string]bool))
|
|
}
|
|
|
|
// buildTreeNodeDepth is the internal implementation with depth tracking.
|
|
func (tm *TreeManager) buildTreeNodeDepth(id string, depth int, visited map[string]bool) *TreeNode {
|
|
const maxDepth = 1000
|
|
if depth > maxDepth {
|
|
// Cycle or extremely deep tree detected, stop recursing
|
|
return nil
|
|
}
|
|
if visited[id] {
|
|
// Cycle detected, stop recursing
|
|
return nil
|
|
}
|
|
|
|
entry, ok := tm.index[id]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
|
|
visited[id] = true
|
|
defer delete(visited, id)
|
|
|
|
node := &TreeNode{
|
|
Entry: entry,
|
|
ID: id,
|
|
ParentID: tm.entryParentID(entry),
|
|
}
|
|
|
|
for _, childID := range tm.childIndex[id] {
|
|
child := tm.buildTreeNodeDepth(childID, depth+1, visited)
|
|
if child != nil {
|
|
node.Children = append(node.Children, child)
|
|
}
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
// --- Path conventions ---
|
|
|
|
// DefaultSessionDir returns the default session storage directory for a cwd.
|
|
// Convention: ~/.kit/sessions/<encoded-cwd>, where path separators are
|
|
// encoded as "--" with no leading or trailing dashes — e.g.
|
|
// /home/user/proj becomes home--user--proj. See encodeCwdForDir for the
|
|
// full encoding rules (including Windows path handling).
|
|
func DefaultSessionDir(cwd string) string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
home = "."
|
|
}
|
|
return filepath.Join(home, ".kit", "sessions", encodeCwdForDir(cwd))
|
|
}
|
|
|
|
// encodeCwdForDir converts a working-directory path into a single, filesystem-
|
|
// safe directory name. Path separators are replaced with double dashes and
|
|
// characters that are illegal in Windows directory names — most importantly
|
|
// the colon that follows the drive letter (e.g. `C:\foo` → `C--foo`) — are
|
|
// stripped. The result is identical to the previous Unix-only encoding for
|
|
// paths that do not contain such characters, so existing session directories
|
|
// are preserved.
|
|
func encodeCwdForDir(cwd string) string {
|
|
// Convert both `/` and `\` to double dashes so encoding is stable across
|
|
// platforms and remains correct on Windows where `filepath.Separator`
|
|
// would otherwise miss forward-slash style paths.
|
|
safeCwd := strings.ReplaceAll(cwd, "\\", "--")
|
|
safeCwd = strings.ReplaceAll(safeCwd, "/", "--")
|
|
// Remove leading separator replacement.
|
|
safeCwd = strings.TrimPrefix(safeCwd, "--")
|
|
// Strip characters that are illegal in directory names on Windows
|
|
// (`< > : " | ? *`). On Unix these characters are legal but rare in
|
|
// practice; stripping them keeps the encoding portable.
|
|
replacer := strings.NewReplacer(
|
|
":", "",
|
|
"<", "",
|
|
">", "",
|
|
"\"", "",
|
|
"|", "",
|
|
"?", "",
|
|
"*", "",
|
|
)
|
|
return replacer.Replace(safeCwd)
|
|
}
|