// Package archive saves a local copy of each run's fetched data and // analysis. It doubles as the source of the previous run's report, which // the prompt uses to dedup successive briefings against each other. package archive import ( "encoding/json" "fmt" "os" "path/filepath" "sort" "strings" "time" "gitea.konchin.com/service/stonks/internal/config" "gitea.konchin.com/service/stonks/internal/fetch" "gitea.konchin.com/service/stonks/internal/report" ) const defaultDir = "~/.local/state/stonks/runs" // Run holds everything one pipeline run produced, for archiving. type Run struct { Result fetch.Result Prompt string // final prompt sent to the analyzer, including any advisor section RawResponse string // analyzer's raw, unparsed response Report report.Report } // Save writes Run's artifacts to a new timestamped subdirectory under // cfg.Dir (or defaultDir if cfg.Dir is empty) and returns that directory's // path. Callers should treat a non-nil error as non-fatal — archiving is a // debugging aid, not part of the report pipeline. func Save(cfg config.ArchiveConfig, run Run, now time.Time) (string, error) { base, err := resolveDir(cfg) if err != nil { return "", err } dir := filepath.Join(base, now.UTC().Format("20060102T150405Z")) if err := os.MkdirAll(dir, 0o755); err != nil { return "", fmt.Errorf("archive: create %q: %w", dir, err) } fetchedJSON, err := json.MarshalIndent(run.Result, "", " ") if err != nil { return "", fmt.Errorf("archive: marshal fetched data: %w", err) } recsJSON, err := json.MarshalIndent(run.Report.Recommendations, "", " ") if err != nil { return "", fmt.Errorf("archive: marshal recommendations: %w", err) } portfolioJSON, err := json.MarshalIndent(run.Report.Portfolio, "", " ") if err != nil { return "", fmt.Errorf("archive: marshal portfolio snapshot: %w", err) } files := map[string][]byte{ "fetched.json": fetchedJSON, "prompt.txt": []byte(run.Prompt), "analysis_raw.md": []byte(run.RawResponse), "report.md": []byte(run.Report.Text), "recommendations.json": recsJSON, "portfolio.json": portfolioJSON, "memory.md": []byte(run.Report.Memory), } for name, data := range files { if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil { return dir, fmt.Errorf("archive: write %q: %w", name, err) } } return dir, nil } // RecentRun is one previously-archived run's timestamp and report text, as // returned by LoadRecent. type RecentRun struct { When time.Time Report string } // runDirLayout is the timestamp format run directories are named with (see // Save), used by LoadRecent to recover each run's time. const runDirLayout = "20060102T150405Z" // LoadRecent reads back up to the last n previously-archived runs' report.md // contents, oldest first, for the prompt to dedup the next briefing against // a rolling window of recent history. Returns (nil, nil) if no prior run // exists yet (e.g. first-ever run) — callers should treat that as "no // previous reports" rather than an error. Runs whose report.md is missing or // unreadable are skipped rather than failing the whole call. func LoadRecent(cfg config.ArchiveConfig, n int) ([]RecentRun, error) { base, err := resolveDir(cfg) if err != nil { return nil, err } entries, err := os.ReadDir(base) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, fmt.Errorf("archive: list %q: %w", base, err) } var runDirs []string for _, e := range entries { if e.IsDir() { runDirs = append(runDirs, e.Name()) } } if len(runDirs) == 0 { return nil, nil } sort.Strings(runDirs) // timestamp-named dirs sort chronologically if len(runDirs) > n { runDirs = runDirs[len(runDirs)-n:] } var runs []RecentRun for _, name := range runDirs { when, err := time.Parse(runDirLayout, name) if err != nil { continue } data, err := os.ReadFile(filepath.Join(base, name, "report.md")) if err != nil { continue } runs = append(runs, RecentRun{When: when.UTC(), Report: string(data)}) } return runs, nil } // resolveDir returns cfg.Dir (or defaultDir if empty) with "~" expanded. func resolveDir(cfg config.ArchiveConfig) (string, error) { base := cfg.Dir if base == "" { base = defaultDir } base, err := expandHome(base) if err != nil { return "", fmt.Errorf("archive: %w", err) } return base, nil } // expandHome replaces a leading "~" with the current user's home directory. func expandHome(path string) (string, error) { if path != "~" && !strings.HasPrefix(path, "~/") { return path, nil } home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("resolve home directory: %w", err) } if path == "~" { return home, nil } return filepath.Join(home, path[2:]), nil }