Commit 44ae208602b1

Vincent Demeester <vincent@sbr.pm>
2026-03-27 11:22:32
feat(daily-plan): add AI session summaries to weekly review
Scans ~/.local/share/ai/ for sessions, learnings, and research created during the week. Sessions shown as compact day-grouped summary with 3 notable titles each. Auto-recovered sessions filtered out. Learnings and research listed individually.
1 parent 4d62628
Changed files (4)
tools
daily-plan
cmd
daily-plan
internal
tools/daily-plan/cmd/daily-plan/main.go
@@ -17,6 +17,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/vdemeester/home/tools/daily-plan/internal/ai"
 	"github.com/vdemeester/home/tools/daily-plan/internal/cache"
 	"github.com/vdemeester/home/tools/daily-plan/internal/config"
 	"github.com/vdemeester/home/tools/daily-plan/internal/display"
@@ -188,6 +189,20 @@ func ghToJSON(items []github.Item) []jsonGH {
 	return out
 }
 
+func aiToJSON(items []ai.Item) []jsonAI {
+	result := make([]jsonAI, 0, len(items))
+	for _, item := range items {
+		result = append(result, jsonAI{
+			Title:   item.Title,
+			Type:    item.Type,
+			Date:    item.Date.Format("2006-01-02"),
+			Tool:    item.Tool,
+			Project: item.Project,
+		})
+	}
+	return result
+}
+
 func orgDoneToJSON(items []org.DoneItem) []jsonOrgDone {
 	result := make([]jsonOrgDone, 0, len(items))
 	for _, d := range items {
@@ -539,10 +554,19 @@ type jsonOrgDone struct {
 	CompletedAt string `json:"completed_at"`
 }
 
+type jsonAI struct {
+	Title   string `json:"title"`
+	Type    string `json:"type"`
+	Date    string `json:"date"`
+	Tool    string `json:"tool,omitempty"`
+	Project string `json:"project,omitempty"`
+}
+
 type jsonWeekly struct {
 	Week            string           `json:"week"`
 	NextMonday      string           `json:"next_monday"`
 	OrgDone         []jsonOrgDone    `json:"org_done,omitempty"`
+	AISessions      []jsonAI         `json:"ai_sessions,omitempty"`
 	JiraCompleted   []jsonJira       `json:"jira_completed"`
 	GHMerged        []jsonGH         `json:"github_merged"`
 	GHReviewed      []jsonGH         `json:"github_reviewed"`
@@ -589,6 +613,10 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 		return org.FetchDoneItems(cfg.Org.Files, cfg.Org.ArchiveDir, monday, nextMonday)
 	})
 
+	aiItems, _ := cache.GetOrFetch(apiCache, "ai:items:"+mondayStr, func() ([]ai.Item, error) {
+		return ai.FetchItemsSince(cfg.AI.Dir, monday, nextMonday)
+	})
+
 	var discussions []github.DiscussionItem
 	var comments []github.CommentItem
 	if !noDiscussions {
@@ -607,6 +635,7 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 			Week:            monday.Format("2006-01-02"),
 			NextMonday:      nextMonday.Format("2006-01-02"),
 			OrgDone:         orgDoneToJSON(orgDone),
+			AISessions:      aiToJSON(aiItems),
 			JiraCompleted:   jiraToJSON(completed, cfg.Jira.BaseURL),
 			GHMerged:        ghToJSON(merged),
 			GHReviewed:      ghToJSON(reviewed),
@@ -631,6 +660,11 @@ func cmdWeekly(ctx context.Context, cfg *config.Config) error {
 		display.OrgDoneItems(orgDone)
 	}
 
+	if len(aiItems) > 0 {
+		display.SubHeader("AI Sessions This Week")
+		display.AIItems(aiItems)
+	}
+
 	display.SubHeader("Completed This Week (Jira)")
 	display.JiraIssues(completed, "done")
 
tools/daily-plan/internal/ai/ai.go
@@ -0,0 +1,167 @@
+// Package ai scans AI session history from ~/.local/share/ai/.
+package ai
+
+import (
+	"bufio"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+	"time"
+)
+
+var (
+	// **Date:** 2026-03-26
+	mdDateRe = regexp.MustCompile(`^\*\*Date:\*\*\s+(\d{4}-\d{2}-\d{2})`)
+	// **Tool:** pi
+	mdToolRe = regexp.MustCompile(`^\*\*Tool:\*\*\s+(.+)`)
+	// **Project:** vdemeester/home
+	mdProjectRe = regexp.MustCompile(`^\*\*Project:\*\*\s+(.+)`)
+	// # Title or # Some Title Here
+	mdTitleRe = regexp.MustCompile(`^#\s+(.+)$`)
+	// Filename: 2026-03-26-description.md
+	filenameDateRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})`)
+)
+
+// Item represents an AI session, learning, or research entry.
+type Item struct {
+	Title   string
+	Type    string // "session", "learning", "research"
+	Date    time.Time
+	Tool    string
+	Project string
+	Path    string
+}
+
+// FetchItemsSince scans AI storage directories for items within the time range.
+func FetchItemsSince(baseDir string, start, end time.Time) ([]Item, error) {
+	var items []Item
+
+	types := []struct {
+		dir      string
+		itemType string
+	}{
+		{"sessions", "session"},
+		{"learnings", "learning"},
+		{"research", "research"},
+	}
+
+	for _, t := range types {
+		dir := filepath.Join(baseDir, t.dir)
+		fetched, err := scanDir(dir, t.itemType, start, end)
+		if err != nil {
+			continue
+		}
+		items = append(items, fetched...)
+	}
+
+	return items, nil
+}
+
+func scanDir(baseDir, itemType string, start, end time.Time) ([]Item, error) {
+	resolved, err := filepath.EvalSymlinks(baseDir)
+	if err != nil {
+		resolved = baseDir
+	}
+
+	var items []Item
+
+	err = filepath.Walk(resolved, func(path string, info os.FileInfo, err error) error {
+		if err != nil || info.IsDir() {
+			return nil
+		}
+
+		ext := strings.ToLower(filepath.Ext(path))
+		if ext != ".md" {
+			return nil
+		}
+
+		// Skip small files, export dirs
+		if info.Size() < 50 || strings.Contains(path, "/exports/") {
+			return nil
+		}
+
+		// Quick date filter from filename before parsing
+		basename := filepath.Base(path)
+		if m := filenameDateRe.FindString(basename); m != "" {
+			if d, err := time.Parse("2006-01-02", m); err == nil {
+				if d.Before(start.Truncate(24*time.Hour)) || d.After(end) {
+					return nil
+				}
+			}
+		}
+
+		item, err := parseFile(path, itemType)
+		if err != nil {
+			return nil
+		}
+
+		if item.Date.Before(start) || item.Date.After(end) {
+			return nil
+		}
+
+		items = append(items, *item)
+		return nil
+	})
+
+	return items, err
+}
+
+func parseFile(path, itemType string) (*Item, error) {
+	f, err := os.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+
+	item := &Item{
+		Type: itemType,
+		Path: path,
+	}
+
+	scanner := bufio.NewScanner(f)
+	lines := 0
+	for scanner.Scan() && lines < 30 {
+		line := scanner.Text()
+		lines++
+
+		if m := mdDateRe.FindStringSubmatch(line); len(m) > 1 && item.Date.IsZero() {
+			if d, err := time.Parse("2006-01-02", m[1]); err == nil {
+				item.Date = d
+			}
+		}
+		if m := mdToolRe.FindStringSubmatch(line); len(m) > 1 && item.Tool == "" {
+			item.Tool = strings.TrimSpace(m[1])
+		}
+		if m := mdProjectRe.FindStringSubmatch(line); len(m) > 1 && item.Project == "" {
+			item.Project = strings.TrimSpace(m[1])
+		}
+		if m := mdTitleRe.FindStringSubmatch(line); len(m) > 1 && item.Title == "" {
+			item.Title = strings.TrimSpace(m[1])
+		}
+	}
+
+	// Fallback title from filename
+	if item.Title == "" {
+		item.Title = cleanFilename(filepath.Base(path))
+	}
+
+	// Fallback date from filename
+	if item.Date.IsZero() {
+		if m := filenameDateRe.FindString(filepath.Base(path)); m != "" {
+			if d, err := time.Parse("2006-01-02", m); err == nil {
+				item.Date = d
+			}
+		}
+	}
+
+	return item, nil
+}
+
+func cleanFilename(name string) string {
+	name = strings.TrimSuffix(name, filepath.Ext(name))
+	name = filenameDateRe.ReplaceAllString(name, "")
+	name = strings.TrimPrefix(name, "-")
+	name = strings.ReplaceAll(name, "-", " ")
+	return strings.TrimSpace(name)
+}
tools/daily-plan/internal/config/config.go
@@ -17,10 +17,18 @@ type Config struct {
 	// Org-mode configuration
 	Org OrgConfig
 
+	// AI session history configuration
+	AI AIConfig
+
 	// State directory for last-check tracking
 	StateDir string
 }
 
+// AIConfig configures AI session history scanning.
+type AIConfig struct {
+	Dir string // Base directory (e.g. ~/.local/share/ai)
+}
+
 // JiraConfig configures Jira integration.
 type JiraConfig struct {
 	User    string // Jira username (e.g. "vdemeest")
@@ -85,6 +93,9 @@ func DefaultConfig() *Config {
 			},
 			ArchiveDir: filepath.Join(home, "desktop", "org", "archive"),
 		},
+		AI: AIConfig{
+			Dir: filepath.Join(home, ".local", "share", "ai"),
+		},
 		StateDir: filepath.Join(home, ".local", "share", "daily-plan"),
 	}
 }
tools/daily-plan/internal/display/display.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"strings"
 
+	"github.com/vdemeester/home/tools/daily-plan/internal/ai"
 	"github.com/vdemeester/home/tools/daily-plan/internal/github"
 	"github.com/vdemeester/home/tools/daily-plan/internal/jira"
 	"github.com/vdemeester/home/tools/daily-plan/internal/org"
@@ -230,6 +231,91 @@ func OrgDoneItems(items []org.DoneItem) {
 	}
 }
 
+// AIItems prints AI session/learning/research items grouped by type.
+// Sessions are shown as a compact summary (count per day + notable titles).
+// Learnings and research are shown individually.
+func AIItems(items []ai.Item) {
+	if len(items) == 0 {
+		fmt.Printf("  %s(none)%s\n", dim, reset)
+		return
+	}
+	// Separate by type
+	var sessions, learnings, research []ai.Item
+	for _, item := range items {
+		// Skip auto-recovered noise
+		if strings.Contains(strings.ToLower(item.Title), "auto-recovered") {
+			continue
+		}
+		switch item.Type {
+		case "session":
+			sessions = append(sessions, item)
+		case "learning":
+			learnings = append(learnings, item)
+		case "research":
+			research = append(research, item)
+		}
+	}
+
+	if len(sessions) > 0 {
+		// Group sessions by date and show counts + notable titles
+		byDate := make(map[string][]ai.Item)
+		var dates []string
+		for _, s := range sessions {
+			d := s.Date.Format("2006-01-02")
+			if _, exists := byDate[d]; !exists {
+				dates = append(dates, d)
+			}
+			byDate[d] = append(byDate[d], s)
+		}
+		fmt.Printf("  %s%sSessions (%d total)%s\n", dim, bold, len(sessions), reset)
+		for _, d := range dates {
+			daySessions := byDate[d]
+			// Show up to 3 notable titles per day
+			fmt.Printf("    %s%s%s — %d sessions\n", cyan, d, reset, len(daySessions))
+			shown := 0
+			for _, s := range daySessions {
+				if shown >= 3 {
+					remaining := len(daySessions) - shown
+					if remaining > 0 {
+						fmt.Printf("      %s... and %d more%s\n", dim, remaining, reset)
+					}
+					break
+				}
+				title := s.Title
+				// Strip "Session: " prefix if present
+				title = strings.TrimPrefix(title, "Session: ")
+				if len(title) > 55 {
+					title = title[:52] + "..."
+				}
+				project := ""
+				if s.Project != "" {
+					project = fmt.Sprintf(" %s[%s]%s", dim, s.Project, reset)
+				}
+				fmt.Printf("      • %s%s\n", title, project)
+				shown++
+			}
+		}
+	}
+
+	printAIList := func(label string, items []ai.Item) {
+		if len(items) == 0 {
+			return
+		}
+		fmt.Printf("  %s%s%s (%d)%s\n", dim, bold, label, len(items), reset)
+		for _, item := range items {
+			title := item.Title
+			if len(title) > 60 {
+				title = title[:57] + "..."
+			}
+			fmt.Printf("    %s•%s %s %s%s%s\n",
+				cyan, reset, title, dim, item.Date.Format("2006-01-02"), reset)
+		}
+	}
+
+	printAIList("Learnings", learnings)
+	printAIList("Research", research)
+}
+
 // DiscussionItems prints GitHub discussions.
 func DiscussionItems(items []github.DiscussionItem) {
 	if len(items) == 0 {