sketch: add session history tools using markdown table API

Add session history tools to allow the LLM to access previous sketch sessions:
- New list_recent_sketch_sessions and read_sketch_session tools
- Integration with skaband client for session data retrieval
- Session history tools automatically added when skaband client available
- Updated agent configuration to include skaband client
- Client handles plain text markdown table response from API
- Display server-generated markdown table directly to LLM

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s693acdfdaaa392c8k
diff --git a/skabandclient/skabandclient.go b/skabandclient/skabandclient.go
index d13fb77..5abff35 100644
--- a/skabandclient/skabandclient.go
+++ b/skabandclient/skabandclient.go
@@ -29,56 +29,21 @@
 	"golang.org/x/net/http2"
 )
 
-// DialAndServeLoop is a redial loop around DialAndServe.
-func DialAndServeLoop(ctx context.Context, skabandAddr, sessionID, clientPubKey string, srv http.Handler, connectFn func(connected bool)) {
-	if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
-		if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil {
-			skabandAddr = addr
-		}
-	}
+// SketchSession represents a sketch session with metadata
+type SketchSession struct {
+	ID               string    `json:"id"`
+	Title            string    `json:"title"`
+	FirstMessage     string    `json:"first_message"`
+	LastMessage      string    `json:"last_message"`
+	FirstMessageDate time.Time `json:"first_message_date"`
+	LastMessageDate  time.Time `json:"last_message_date"`
+}
 
-	var skabandConnected atomic.Bool
-	skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == "/skabandinit" {
-			b, err := io.ReadAll(r.Body)
-			if err != nil {
-				fmt.Printf("skabandinit failed: %v\n", err)
-				return
-			}
-			m := map[string]string{}
-			if err := json.Unmarshal(b, &m); err != nil {
-				fmt.Printf("skabandinit failed: %v\n", err)
-				return
-			}
-			skabandConnected.Store(true)
-			if connectFn != nil {
-				connectFn(true)
-			}
-			return
-		}
-		srv.ServeHTTP(w, r)
-	})
-
-	var lastErrLog time.Time
-	for {
-		if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, skabandHandler); err != nil {
-			// NOTE: *just* backoff the logging. Backing off dialing
-			// is bad UX. Doing so saves negligible CPU and doing so
-			// without hurting UX requires interrupting the backoff with
-			// wake-from-sleep and network-up events from the OS,
-			// which are a pain to plumb.
-			if time.Since(lastErrLog) > 1*time.Minute {
-				slog.DebugContext(ctx, "skaband connection failed", "err", err)
-				lastErrLog = time.Now()
-			}
-		}
-		if skabandConnected.CompareAndSwap(true, false) {
-			if connectFn != nil {
-				connectFn(false)
-			}
-		}
-		time.Sleep(200 * time.Millisecond)
-	}
+// SkabandClient provides HTTP client functionality for skaband server
+type SkabandClient struct {
+	addr      string
+	publicKey string
+	client    *http.Client
 }
 
 func DialAndServe(ctx context.Context, hostURL, sessionID, clientPubKey string, h http.Handler) (err error) {
@@ -302,3 +267,149 @@
 	}
 	return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
 }
+
+// NewSkabandClient creates a new skaband client
+func NewSkabandClient(addr, publicKey string) *SkabandClient {
+	// Apply localhost-to-docker-internal transformation if needed
+	if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
+		if newAddr, err := LocalhostToDockerInternal(addr); err == nil {
+			addr = newAddr
+		}
+	}
+
+	return &SkabandClient{
+		addr:      addr,
+		publicKey: publicKey,
+		client:    &http.Client{Timeout: 30 * time.Second},
+	}
+}
+
+// ListRecentSketchSessionsMarkdown returns recent sessions as a markdown table
+func (c *SkabandClient) ListRecentSketchSessionsMarkdown(ctx context.Context, currentRepo, sessionID string) (string, error) {
+	if c == nil {
+		return "", fmt.Errorf("SkabandClient is nil")
+	}
+
+	// Build URL with query parameters
+	baseURL := c.addr + "/api/sessions/recent"
+	if currentRepo != "" {
+		baseURL += "?repo=" + url.QueryEscape(currentRepo)
+	}
+
+	req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil)
+	if err != nil {
+		return "", fmt.Errorf("failed to create request: %w", err)
+	}
+
+	// Add headers
+	req.Header.Set("Public-Key", c.publicKey)
+	req.Header.Set("Session-ID", sessionID)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return "", fmt.Errorf("failed to make request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return "", fmt.Errorf("failed to read response: %w", err)
+	}
+
+	return string(body), nil
+}
+
+// ReadSketchSession reads the full details of a specific session and returns formatted text
+func (c *SkabandClient) ReadSketchSession(ctx context.Context, targetSessionID, originSessionID string) (*string, error) {
+	if c == nil {
+		return nil, fmt.Errorf("SkabandClient is nil")
+	}
+
+	req, err := http.NewRequestWithContext(ctx, "GET", c.addr+"/api/sessions/"+targetSessionID, nil)
+	if err != nil {
+		return nil, fmt.Errorf("failed to create request: %w", err)
+	}
+
+	// Add headers
+	req.Header.Set("Public-Key", c.publicKey)
+	req.Header.Set("Session-ID", originSessionID)
+
+	resp, err := c.client.Do(req)
+	if err != nil {
+		return nil, fmt.Errorf("failed to make request: %w", err)
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != http.StatusOK {
+		body, _ := io.ReadAll(resp.Body)
+		return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
+	}
+
+	body, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read response: %w", err)
+	}
+
+	response := string(body)
+	return &response, nil
+}
+
+// DialAndServeLoop is a redial loop around DialAndServe.
+func (c *SkabandClient) DialAndServeLoop(ctx context.Context, sessionID string, srv http.Handler, connectFn func(connected bool)) {
+	skabandAddr := c.addr
+	clientPubKey := c.publicKey
+
+	if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
+		if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil {
+			skabandAddr = addr
+		}
+	}
+
+	var skabandConnected atomic.Bool
+	skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/skabandinit" {
+			b, err := io.ReadAll(r.Body)
+			if err != nil {
+				fmt.Printf("skabandinit failed: %v\n", err)
+				return
+			}
+			m := map[string]string{}
+			if err := json.Unmarshal(b, &m); err != nil {
+				fmt.Printf("skabandinit failed: %v\n", err)
+				return
+			}
+			skabandConnected.Store(true)
+			if connectFn != nil {
+				connectFn(true)
+			}
+			return
+		}
+		srv.ServeHTTP(w, r)
+	})
+
+	var lastErrLog time.Time
+	for {
+		if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, skabandHandler); err != nil {
+			// NOTE: *just* backoff the logging. Backing off dialing
+			// is bad UX. Doing so saves negligible CPU and doing so
+			// without hurting UX requires interrupting the backoff with
+			// wake-from-sleep and network-up events from the OS,
+			// which are a pain to plumb.
+			if time.Since(lastErrLog) > 1*time.Minute {
+				slog.DebugContext(ctx, "skaband connection failed", "err", err)
+				lastErrLog = time.Now()
+			}
+		}
+		if skabandConnected.CompareAndSwap(true, false) {
+			if connectFn != nil {
+				connectFn(false)
+			}
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+}