From 03797c9ada8d0e335f7ad986bd45e2b11e309d8b Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Mon, 4 Aug 2025 10:54:34 -0400 Subject: [PATCH 01/11] Refactored app into split files --- VERSION | 2 +- configure.go | 46 +++++++ const.go | 75 +++++++++++ env.go | 42 ++++++ gz.go | 40 ++++++ main.go | 364 +------------------------------------------------- simplify.go | 16 +++ type.go | 31 +++++ type_funcs.go | 52 ++++++++ var.go | 86 ++++++++++++ var_funcs.go | 71 ++++++++++ version.go | 22 +++ 12 files changed, 484 insertions(+), 363 deletions(-) create mode 100644 configure.go create mode 100644 const.go create mode 100644 env.go create mode 100644 gz.go create mode 100644 simplify.go create mode 100644 type.go create mode 100644 type_funcs.go create mode 100644 var.go create mode 100644 var_funcs.go create mode 100644 version.go diff --git a/VERSION b/VERSION index b482243..13637f4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.2 \ No newline at end of file +v1.0.3 \ No newline at end of file diff --git a/configure.go b/configure.go new file mode 100644 index 0000000..8307463 --- /dev/null +++ b/configure.go @@ -0,0 +1,46 @@ +package main + +import ( + "fmt" + "path/filepath" + + "github.com/andreimerlescu/figtree/v2" +) + +// init creates a new figtree with options to use CONFIG_FILE as a way of reading a YAML file while ignoring the env +func configure() { + // figs is a tree of figs that ignore the ENV + figs = figtree.With(figtree.Options{ + Harvest: 9, + IgnoreEnvironment: true, + ConfigFile: envVal(eConfigFile, "./config.yaml"), + }) + + // properties define new fig fruits on the figtree + figs = figs.NewString(kSourceDir, ".", "Absolute path of directory you want to summarize.") + figs = figs.NewString(kOutputDir, filepath.Join(".", "summaries"), fmt.Sprintf("Path of the directory to write the %s file to", newSummaryFilename())) + figs = figs.NewString(kFilename, newSummaryFilename(), "Output file of summary.md") + figs = figs.NewList(kIncludeExt, defaultInclude, "List of extensions to INCLUDE in summary.") + figs = figs.NewList(kExcludeExt, defaultExclude, "List of extensions to EXCLUDE in summary.") + figs = figs.NewList(kSkipContains, defaultAvoid, "List of path substrings if present to skip over full path.") + figs = figs.NewInt(kMaxFiles, 369, "Maximum number of files to process concurrently") + figs = figs.NewInt64(kMaxOutputSize, 1_776_369, "Maximum file size of output file") + figs = figs.NewBool(kDotFiles, false, "Any path that is considered a dotfile can be included by setting this to true") + figs = figs.NewBool(kPrint, envIs(eAlwaysPrint), "Print generated file contents to STDOUT") + figs = figs.NewBool(kWrite, envIs(eAlwaysWrite), "Write generated contents to file") + figs = figs.NewBool(kJson, envIs(eAlwaysJson), "Enable JSON formatting") + figs = figs.NewBool(kCompress, envIs(eAlwaysCompress), "Use gzip compression in output") + figs = figs.NewBool(kVersion, false, "Display current version of summarize") + figs = figs.NewBool(kDebug, false, "Enable debug mode") + + // validators run internal figtree Assure funcs as arguments to validate against + figs = figs.WithValidator(kSourceDir, figtree.AssureStringNotEmpty) + figs = figs.WithValidator(kOutputDir, figtree.AssureStringNotEmpty) + figs = figs.WithValidator(kFilename, figtree.AssureStringNotEmpty) + figs = figs.WithValidator(kMaxFiles, figtree.AssureIntInRange(1, 17_369)) + figs = figs.WithValidator(kMaxOutputSize, figtree.AssureInt64InRange(369, 369_369_369_369)) + + // callbacks as figtree.CallbackAfterVerify run after the Validators above finish + figs = figs.WithCallback(kSourceDir, figtree.CallbackAfterVerify, callbackVerifyReadableDirectory) + figs = figs.WithCallback(kFilename, figtree.CallbackAfterVerify, callbackVerifyFile) +} diff --git a/const.go b/const.go new file mode 100644 index 0000000..55064d7 --- /dev/null +++ b/const.go @@ -0,0 +1,75 @@ +package main + +const ( + projectName string = "github.com/andreimerlescu/summarize" + tFormat string = "2006.01.02.15.04.05.UTC" + + // eConfigFile ENV string of path to .yml|.yaml|.json|.ini file + eConfigFile string = "SUMMARIZE_CONFIG_FILE" + + // eAddIgnoreInPathList ENV string (comma separated list) of substrings to ignore if path contains + eAddIgnoreInPathList string = "SUMMARIZE_IGNORE_CONTAINS" + + // eAddIncludeExtList ENV string (comma separated list) of acceptable file extensions of any scanned path + eAddIncludeExtList string = "SUMMARIZE_INCLUDE_EXT" + + // eAddExcludeExtList ENV string (comma separated list) of rejected file extensions of any scanned path + eAddExcludeExtList string = "SUMMARIZE_EXCLUDE_EXT" + + // eAlwaysWrite ENV string-as-bool (as "TRUE" or "true" for true) always sets -write true in CLI argument parsing + eAlwaysWrite string = "SUMMARIZE_ALWAYS_WRITE" + + // eAlwaysPrint ENV string-as-bool (as "TRUE" or "true" for true) always sets -print true in CLI argument parsing + eAlwaysPrint string = "SUMMARIZE_ALWAYS_PRINT" + + // eAlwaysJson ENV string-as-bool (as "TRUE" or "true" for true) always sets -json true in CLI argument parsing + eAlwaysJson string = "SUMMARIZE_ALWAYS_JSON" + + // eAlwaysCompress ENV string-as-bool (as "TRUE" or "true" for true) always sets -gz true in CLI argument parsing + eAlwaysCompress string = "SUMMARIZE_ALWAYS_COMPRESS" + + // kSourceDir figtree fig string -d for the directory path to generate a summary of + kSourceDir string = "d" + + // kOutputDir figtree fig string -o for the output directory where the summary is saved + kOutputDir string = "o" + + // kIncludeExt figtree fig list (string-as-list aka comma separated list) -i for the extensions to summarize + kIncludeExt string = "i" + + // kExcludeExt figtree fig list (string-as-list aka comma separated list) -x for the extensions NOT to summarize + kExcludeExt string = "x" + + // kSkipContains figtree fig list (string-as-list aka comma separated list) -s for the substrings in the paths to ignore + kSkipContains string = "s" + + // kFilename figtree fig string -f is the name of the file to save inside kOutputDir + kFilename string = "f" + + // kPrint figtree fig bool -print will render to STDOUT the contents of the summary + kPrint string = "print" + + // kMaxOutputSize figtree fig int64 -max will stop summarizing the kSourceDir once kFilename reaches this size in bytes + kMaxOutputSize string = "max" + + // kWrite figtree fig bool -write will write the summary to the kFilename in the kSourceDir + kWrite string = "write" + + // kVersion figtree fig bool -v will display the current version of the binary + kVersion string = "v" + + // kDotFiles figtree fig bool -ndf will skip over any directory that has a prefix of "." + kDotFiles string = "ndf" + + // kMaxFiles figtree fig int64 -mf will specify the maximum number of files that will concurrently be summarized + kMaxFiles string = "mf" + + // kDebug figtree fig bool -debug will render addition log statements to STDOUT + kDebug string = "debug" + + // kJson figtree fig bool -json will render the output as JSON to the console's STDOUT only + kJson string = "json" + + // kCompress figtree fig bool -gz will gzip compress the contents of kFilename that is written to kOutputDir + kCompress string = "gz" +) diff --git a/env.go b/env.go new file mode 100644 index 0000000..a55215a --- /dev/null +++ b/env.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/andreimerlescu/figtree/v2" + "os" + "strconv" +) + +// envVal takes a name for os.LookupEnv with a fallback to return a string +func envVal(name, fallback string) string { + v, ok := os.LookupEnv(name) + if !ok { + return fallback + } + return v +} + +// envIs takes a name for os.LookupEnv with a fallback of false to return a bool +func envIs(name string) bool { + v, ok := os.LookupEnv(name) + if !ok { + return false + } + vb, err := strconv.ParseBool(v) + if err != nil { + return false + } + return vb +} + +// addFromEnv takes a pointer to a slice of strings and a new ENV os.LookupEnv name to return the figtree ToList on the Flesh that sends the list into simplify before being returned +func addFromEnv(e string, l *[]string) { + v, ok := os.LookupEnv(e) + if ok { + flesh := figtree.NewFlesh(v) + maybeAdd := flesh.ToList() + for _, entry := range maybeAdd { + *l = append(*l, entry) + } + } + *l = simplify(*l) +} diff --git a/gz.go b/gz.go new file mode 100644 index 0000000..bf1a033 --- /dev/null +++ b/gz.go @@ -0,0 +1,40 @@ +package main + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" +) + +// compress compresses a string using gzip and returns the compressed bytes +func compress(s []byte) ([]byte, error) { + var buf bytes.Buffer + gzWriter := gzip.NewWriter(&buf) + _, err := gzWriter.Write(s) + if err != nil { + return nil, fmt.Errorf("failed to write to gzip writer: %w", err) + } + err = gzWriter.Close() + if err != nil { + return nil, fmt.Errorf("failed to close gzip writer: %w", err) + } + return buf.Bytes(), nil +} + +// decompress decompresses gzip compressed bytes back to a string +func decompress(compressed []byte) (string, error) { + buf := bytes.NewReader(compressed) + gzReader, err := gzip.NewReader(buf) + if err != nil { + return "", fmt.Errorf("failed to create gzip reader: %w", err) + } + defer func() { + _ = gzReader.Close() + }() + decompressed, err := io.ReadAll(gzReader) + if err != nil { + return "", fmt.Errorf("failed to read from gzip reader: %w", err) + } + return string(decompressed), nil +} diff --git a/main.go b/main.go index c1f87ec..e51d628 100644 --- a/main.go +++ b/main.go @@ -2,209 +2,22 @@ package main import ( "bytes" - "compress/gzip" - "embed" "encoding/json" "fmt" - "io" "io/fs" "os" "path/filepath" "runtime" "slices" - "strconv" "strings" "sync" "sync/atomic" - "time" check "github.com/andreimerlescu/checkfs" "github.com/andreimerlescu/checkfs/directory" - "github.com/andreimerlescu/checkfs/file" - "github.com/andreimerlescu/figtree/v2" "github.com/andreimerlescu/sema" ) -//go:embed VERSION -var versionBytes embed.FS - -var currentVersion string - -func Version() string { - if len(currentVersion) == 0 { - versionBytes, err := versionBytes.ReadFile("VERSION") - if err != nil { - return "" - } - currentVersion = strings.TrimSpace(string(versionBytes)) - } - return currentVersion -} - -const ( - projectName string = "github.com/andreimerlescu/summarize" - tFormat string = "2006.01.02.15.04.05.UTC" - - eConfigFile string = "SUMMARIZE_CONFIG_FILE" - eAddIgnoreInPathList string = "SUMMARIZE_IGNORE_CONTAINS" - eAddIncludeExtList string = "SUMMARIZE_INCLUDE_EXT" - eAddExcludeExtList string = "SUMMARIZE_EXCLUDE_EXT" - eAlwaysWrite string = "SUMMARIZE_ALWAYS_WRITE" - eAlwaysPrint string = "SUMMARIZE_ALWAYS_PRINT" - eAlwaysJson string = "SUMMARIZE_ALWAYS_JSON" - eAlwaysCompress string = "SUMMARIZE_ALWAYS_COMPRESS" - - kSourceDir string = "d" - kOutputDir string = "o" - kIncludeExt string = "i" - kExcludeExt string = "x" - kSkipContains string = "s" - kFilename string = "f" - kPrint string = "print" - kMaxOutputSize string = "max" - kWrite string = "write" - kVersion string = "v" - kDotFiles string = "ndf" - kMaxFiles string = "mf" - kDebug string = "debug" - kJson string = "json" - kCompress string = "gz" -) - -var ( - // figs is a figtree of fruit for configurable command line arguments that bear fruit - figs figtree.Plant - - alwaysWrite = true - - // defaultExclude are the -exc list of extensions that will be skipped automatically - defaultExclude = []string{ - // Compressed archives - "7z", "gz", "xz", "zst", "zstd", "bz", "bz2", "bzip2", "zip", "tar", "rar", "lz4", "lzma", "cab", "arj", - - // Encryption, certificates, and sensitive keys - "crt", "cert", "cer", "key", "pub", "asc", "pem", "p12", "pfx", "jks", "keystore", - "id_rsa", "id_dsa", "id_ed25519", "id_ecdsa", "gpg", "pgp", - - // Binary & executable artifacts - "exe", "dll", "so", "dylib", "bin", "out", "o", "obj", "a", "lib", "dSYM", - "class", "pyc", "pyo", "__pycache__", - "jar", "war", "ear", "apk", "ipa", "dex", "odex", - "wasm", "node", "beam", "elc", - - // System and disk images - "iso", "img", "dmg", "vhd", "vdi", "vmdk", "qcow2", - - // Database files - "db", "sqlite", "sqlite3", "db3", "mdb", "accdb", "sdf", "ldb", - - // Log files - "log", "trace", "dump", "crash", - - // Media files - Images - "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "ico", "svg", "heic", "heif", "raw", "cr2", "nef", "dng", - - // Media files - Audio - "mp3", "wav", "flac", "aac", "ogg", "wma", "m4a", "opus", "aiff", - - // Media files - Video - "mp4", "avi", "mov", "mkv", "webm", "flv", "wmv", "m4v", "3gp", "ogv", - - // Font files - "ttf", "otf", "woff", "woff2", "eot", "fon", "pfb", "pfm", - - // Document formats (typically not source code) - "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", - - // IDE/Editor/Tooling artifacts - "suo", "sln", "user", "ncb", "pdb", "ipch", "ilk", "tlog", "idb", "aps", "res", - "iml", "idea", "vscode", "project", "classpath", "factorypath", "prefs", - "vcxproj", "vcproj", "filters", "xcworkspace", "xcuserstate", "xcscheme", "pbxproj", - "DS_Store", "Thumbs.db", "desktop.ini", - - // Package manager and build artifacts - "lock", "sum", "resolved", // package-lock.json, go.sum, yarn.lock, etc. - - // Temporary and backup files - "tmp", "temp", "swp", "swo", "bak", "backup", "orig", "rej", "patch", - "~", "old", "new", "part", "incomplete", - - // Source maps and minified files (usually generated) - "map", "min.js", "min.css", "bundle.js", "bundle.css", "chunk.js", - - // Configuration that's typically binary or generated - "dat", "data", "cache", "pid", "sock", - - // Version control artifacts (though usually in ignored directories) - "pack", "idx", "rev", - - // Other binary formats - "pickle", "pkl", "npy", "npz", "mat", "rdata", "rds", - } - - // defaultInclude are the -inc list of extensions that will be included in the summary - defaultInclude = []string{ - "go", "ts", "tf", "sh", "py", "js", "Makefile", "mod", "Dockerfile", "dockerignore", "gitignore", "esconfigs", "md", - } - - // defaultAvoid are the -avoid list of substrings in file path names to avoid in the summary - defaultAvoid = []string{ - ".min.js", ".min.css", ".git/", ".svn/", ".vscode/", ".vs/", ".idea/", "logs/", "secrets/", - ".venv/", "/site-packages", ".terraform/", "summaries/", "node_modules/", "/tmp", "tmp/", "logs/", - } -) - -// newSummaryFilename returns summary.time.Now().UTC().Format(tFormat).md -var newSummaryFilename = func() string { - return fmt.Sprintf("summary.%s.md", time.Now().UTC().Format(tFormat)) -} - -// init creates a new figtree with options to use CONFIG_FILE as a way of reading a YAML file while ignoring the env -func configure() { - figs = figtree.With(figtree.Options{ - Harvest: 9, - IgnoreEnvironment: true, - ConfigFile: envVal(eConfigFile, "./config.yaml"), - }) - // properties - figs = figs.NewString(kSourceDir, ".", "Absolute path of directory you want to summarize.") - figs = figs.NewString(kOutputDir, filepath.Join(".", "summaries"), fmt.Sprintf("Path of the directory to write the %s file to", newSummaryFilename())) - figs = figs.NewString(kFilename, newSummaryFilename(), "Output file of summary.md") - figs = figs.NewList(kIncludeExt, defaultInclude, "List of extensions to INCLUDE in summary.") - figs = figs.NewList(kExcludeExt, defaultExclude, "List of extensions to EXCLUDE in summary.") - figs = figs.NewList(kSkipContains, defaultAvoid, "List of path substrings if present to skip over full path.") - figs = figs.NewInt(kMaxFiles, 369, "Maximum number of files to process concurrently") - figs = figs.NewInt64(kMaxOutputSize, 1_776_369, "Maximum file size of output file") - figs = figs.NewBool(kDotFiles, false, "Any path that is considered a dotfile can be included by setting this to true") - figs = figs.NewBool(kPrint, envIs(eAlwaysPrint), "Print generated file contents to STDOUT") - figs = figs.NewBool(kWrite, envIs(eAlwaysWrite), "Write generated contents to file") - figs = figs.NewBool(kJson, envIs(eAlwaysJson), "Enable JSON formatting") - figs = figs.NewBool(kCompress, envIs(eAlwaysCompress), "Use gzip compression in output") - figs = figs.NewBool(kVersion, false, "Display current version of summarize") - figs = figs.NewBool(kDebug, false, "Enable debug mode") - // validators - figs = figs.WithValidator(kSourceDir, figtree.AssureStringNotEmpty) - figs = figs.WithValidator(kOutputDir, figtree.AssureStringNotEmpty) - figs = figs.WithValidator(kFilename, figtree.AssureStringNotEmpty) - figs = figs.WithValidator(kMaxFiles, figtree.AssureIntInRange(1, 17_369)) - figs = figs.WithValidator(kMaxOutputSize, figtree.AssureInt64InRange(369, 369_369_369_369)) - // callbacks - figs = figs.WithCallback(kSourceDir, figtree.CallbackAfterVerify, callbackVerifyReadableDirectory) - figs = figs.WithCallback(kFilename, figtree.CallbackAfterVerify, callbackVerifyFile) -} - -type result struct { - Path string `yaml:"path" json:"path"` - Contents []byte `yaml:"contents" json:"contents"` - Size int64 `yaml:"size" json:"size"` -} - -type final struct { - Path string `yaml:"path" json:"path"` - Contents string `yaml:"contents" json:"contents"` - Size int64 `yaml:"size" json:"size"` -} - func main() { configure() capture("figs loading environment", figs.Load()) @@ -471,8 +284,8 @@ func main() { } paths := slices.Clone(thisData.Paths) - throttler.Acquire() // throttler is used to protect the runtime from excessive use - wg.Add(1) // wg is used to prevent the runtime from exiting early + throttler.Acquire() // throttler is used to protect the runtime from excessive use + wg.Add(1) // wg is used to prevent the runtime from exiting early go func(innerData *mapData, toUpdate *[]mapData, ext string, paths []string) { // run this extension in a goroutine defer throttler.Release() // when we're done, release the throttler defer wg.Done() // then tell the sync.WaitGroup that we are done @@ -583,176 +396,3 @@ func main() { ) } } - -var callbackVerifyFile = func(value interface{}) error { - return check.File(toString(value), file.Options{Exists: false}) -} - -var callbackVerifyReadableDirectory = func(value interface{}) error { - return check.Directory(toString(value), directory.Options{Exists: true, MorePermissiveThan: 0444}) -} - -var toString = func(value interface{}) string { - switch v := value.(type) { - case string: - return v - case *string: - return *v - default: - flesh := figtree.NewFlesh(value) - f := fmt.Sprintf("%v", flesh.ToString()) - return f - } -} - -var capture = func(msg string, d ...error) { - if len(d) == 0 || (len(d) == 1 && d[0] == nil) { - return - } - terminate(os.Stderr, "[EXCUSE ME, BUT] %s\n\ncaptured error: %v\n", msg, d) -} - -type m struct { - Message string `json:"message"` -} - -var terminate = func(d io.Writer, i string, e ...interface{}) { - for _, f := range os.Args { - if strings.HasPrefix(f, "-json") { - mm := m{Message: fmt.Sprintf(i, e...)} - jb, err := json.MarshalIndent(mm, "", " ") - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "Error serializing json: %v\n", err) - _, _ = fmt.Fprintf(d, i, e...) - } else { - fmt.Println(string(jb)) - } - os.Exit(1) - } - } - _, _ = fmt.Fprintf(d, i, e...) - os.Exit(1) -} - -func simplify(t []string) []string { - seen := make(map[string]bool) - for _, v := range t { - seen[v] = true - } - results := make([]string, len(t)) - for i, v := range t { - if seen[v] { - results[i] = v - } - } - return results -} -func addFromEnv(e string, l *[]string) { - v, ok := os.LookupEnv(e) - if ok { - flesh := figtree.NewFlesh(v) - maybeAdd := flesh.ToList() - for _, entry := range maybeAdd { - *l = append(*l, entry) - } - } - *l = simplify(*l) -} - -type seenStrings struct { - mu sync.RWMutex - m map[string]bool -} - -func (s *seenStrings) Add(entry string) { - s.mu.Lock() - defer s.mu.Unlock() - s.m[entry] = true -} -func (s *seenStrings) Remove(entry string) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.m, entry) -} - -func (s *seenStrings) Len() int { - s.mu.RLock() - defer s.mu.RUnlock() - return len(s.m) -} - -func (s *seenStrings) String() string { - s.mu.RLock() - defer s.mu.RUnlock() - return fmt.Sprint(s.m) -} - -func (s *seenStrings) True(entry string) { - s.mu.Lock() - defer s.mu.Unlock() - s.m[entry] = true -} - -func (s *seenStrings) False(entry string) { - s.mu.Lock() - defer s.mu.Unlock() - delete(s.m, entry) -} - -func (s *seenStrings) Exists(entry string) bool { - s.mu.RLock() - defer s.mu.RUnlock() - return s.m[entry] -} - -func envVal(name, fallback string) string { - v, ok := os.LookupEnv(name) - if !ok { - return fallback - } - return v -} - -func envIs(name string) bool { - v, ok := os.LookupEnv(name) - if !ok { - return false - } - vb, err := strconv.ParseBool(v) - if err != nil { - return false - } - return vb -} - -// compress compresses a string using gzip and returns the compressed bytes -func compress(s []byte) ([]byte, error) { - var buf bytes.Buffer - gzWriter := gzip.NewWriter(&buf) - _, err := gzWriter.Write(s) - if err != nil { - return nil, fmt.Errorf("failed to write to gzip writer: %w", err) - } - err = gzWriter.Close() - if err != nil { - return nil, fmt.Errorf("failed to close gzip writer: %w", err) - } - return buf.Bytes(), nil -} - -// decompress decompresses gzip compressed bytes back to a string -func decompress(compressed []byte) (string, error) { - buf := bytes.NewReader(compressed) - gzReader, err := gzip.NewReader(buf) - if err != nil { - return "", fmt.Errorf("failed to create gzip reader: %w", err) - } - defer func() { - _ = gzReader.Close() - }() - decompressed, err := io.ReadAll(gzReader) - if err != nil { - return "", fmt.Errorf("failed to read from gzip reader: %w", err) - } - return string(decompressed), nil -} diff --git a/simplify.go b/simplify.go new file mode 100644 index 0000000..2fd5b02 --- /dev/null +++ b/simplify.go @@ -0,0 +1,16 @@ +package main + +// simplify takes a list of strings and reduces duplicates from the slice +func simplify(t []string) []string { + seen := make(map[string]bool) + for _, v := range t { + seen[v] = true + } + results := make([]string, len(t)) + for i, v := range t { + if seen[v] { + results[i] = v + } + } + return results +} diff --git a/type.go b/type.go new file mode 100644 index 0000000..6ee5c69 --- /dev/null +++ b/type.go @@ -0,0 +1,31 @@ +package main + +import "sync" + +type ( + + // result contains the scanned path in the kSourceDir that matched the conditions and shall be included in the final summary of kFilename + result struct { + Path string `yaml:"path" json:"path"` + Contents []byte `yaml:"contents" json:"contents"` + Size int64 `yaml:"size" json:"size"` + } + + // final contains the rendered result of the matched path that gets written to kFilename + final struct { + Path string `yaml:"path" json:"path"` + Contents string `yaml:"contents" json:"contents"` + Size int64 `yaml:"size" json:"size"` + } + + // m defines a Message that should be rendered to JSON + m struct { + Message string `json:"message"` + } + + // seenStrings captures a concurrent safe map of strings and booleans that indicate whether the string has been seen + seenStrings struct { + mu sync.RWMutex + m map[string]bool + } +) diff --git a/type_funcs.go b/type_funcs.go new file mode 100644 index 0000000..3800f00 --- /dev/null +++ b/type_funcs.go @@ -0,0 +1,52 @@ +package main + +import "fmt" + +// Add inserts an entry into the map +func (s *seenStrings) Add(entry string) { + s.mu.Lock() + defer s.mu.Unlock() + s.m[entry] = true +} + +// Remove uses delete on the entry in the map +func (s *seenStrings) Remove(entry string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, entry) +} + +// Len returns the length of the map +func (s *seenStrings) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.m) +} + +// String implements the Stringer interface +func (s *seenStrings) String() string { + s.mu.RLock() + defer s.mu.RUnlock() + return fmt.Sprint(s.m) +} + +// True sets the entry to true +func (s *seenStrings) True(entry string) { + s.mu.Lock() + defer s.mu.Unlock() + s.m[entry] = true +} + +// False sets the entry to false +func (s *seenStrings) False(entry string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.m, entry) +} + +// Exists returns a bool if the map contains the entry +func (s *seenStrings) Exists(entry string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.m[entry] +} diff --git a/var.go b/var.go new file mode 100644 index 0000000..755c70b --- /dev/null +++ b/var.go @@ -0,0 +1,86 @@ +package main + +import ( + "github.com/andreimerlescu/figtree/v2" +) + +var ( + // figs is a figtree of fruit for configurable command line arguments that bear fruit + figs figtree.Plant + + // defaultExclude are the -exc list of extensions that will be skipped automatically + defaultExclude = []string{ + // Compressed archives + "7z", "gz", "xz", "zst", "zstd", "bz", "bz2", "bzip2", "zip", "tar", "rar", "lz4", "lzma", "cab", "arj", + + // Encryption, certificates, and sensitive keys + "crt", "cert", "cer", "key", "pub", "asc", "pem", "p12", "pfx", "jks", "keystore", + "id_rsa", "id_dsa", "id_ed25519", "id_ecdsa", "gpg", "pgp", + + // Binary & executable artifacts + "exe", "dll", "so", "dylib", "bin", "out", "o", "obj", "a", "lib", "dSYM", + "class", "pyc", "pyo", "__pycache__", + "jar", "war", "ear", "apk", "ipa", "dex", "odex", + "wasm", "node", "beam", "elc", + + // System and disk images + "iso", "img", "dmg", "vhd", "vdi", "vmdk", "qcow2", + + // Database files + "db", "sqlite", "sqlite3", "db3", "mdb", "accdb", "sdf", "ldb", + + // Log files + "log", "trace", "dump", "crash", + + // Media files - Images + "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "ico", "svg", "heic", "heif", "raw", "cr2", "nef", "dng", + + // Media files - Audio + "mp3", "wav", "flac", "aac", "ogg", "wma", "m4a", "opus", "aiff", + + // Media files - Video + "mp4", "avi", "mov", "mkv", "webm", "flv", "wmv", "m4v", "3gp", "ogv", + + // Font files + "ttf", "otf", "woff", "woff2", "eot", "fon", "pfb", "pfm", + + // Document formats (typically not source code) + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", + + // IDE/Editor/Tooling artifacts + "suo", "sln", "user", "ncb", "pdb", "ipch", "ilk", "tlog", "idb", "aps", "res", + "iml", "idea", "vscode", "project", "classpath", "factorypath", "prefs", + "vcxproj", "vcproj", "filters", "xcworkspace", "xcuserstate", "xcscheme", "pbxproj", + "DS_Store", "Thumbs.db", "desktop.ini", + + // Package manager and build artifacts + "lock", "sum", "resolved", // package-lock.json, go.sum, yarn.lock, etc. + + // Temporary and backup files + "tmp", "temp", "swp", "swo", "bak", "backup", "orig", "rej", "patch", + "~", "old", "new", "part", "incomplete", + + // Source maps and minified files (usually generated) + "map", "min.js", "min.css", "bundle.js", "bundle.css", "chunk.js", + + // Configuration that's typically binary or generated + "dat", "data", "cache", "pid", "sock", + + // Version control artifacts (though usually in ignored directories) + "pack", "idx", "rev", + + // Other binary formats + "pickle", "pkl", "npy", "npz", "mat", "rdata", "rds", + } + + // defaultInclude are the -inc list of extensions that will be included in the summary + defaultInclude = []string{ + "go", "ts", "tf", "sh", "py", "js", "Makefile", "mod", "Dockerfile", "dockerignore", "gitignore", "esconfigs", "md", + } + + // defaultAvoid are the -avoid list of substrings in file path names to avoid in the summary + defaultAvoid = []string{ + ".min.js", ".min.css", ".git/", ".svn/", ".vscode/", ".vs/", ".idea/", "logs/", "secrets/", + ".venv/", "/site-packages", ".terraform/", "summaries/", "node_modules/", "/tmp", "tmp/", "logs/", + } +) diff --git a/var_funcs.go b/var_funcs.go new file mode 100644 index 0000000..2be5495 --- /dev/null +++ b/var_funcs.go @@ -0,0 +1,71 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "time" + + check "github.com/andreimerlescu/checkfs" + "github.com/andreimerlescu/checkfs/directory" + "github.com/andreimerlescu/checkfs/file" + "github.com/andreimerlescu/figtree/v2" +) + +// newSummaryFilename returns summary.time.Now().UTC().Format(tFormat).md +var newSummaryFilename = func() string { + return fmt.Sprintf("summary.%s.md", time.Now().UTC().Format(tFormat)) +} + +// callbackVerifyFile is a figtree WithCallback on the kFilename fig that uses checkfs.File to validate the file does NOT already exist +var callbackVerifyFile = func(value interface{}) error { + return check.File(toString(value), file.Options{Exists: false}) +} + +// callbackVerifyReadableDirectory is a figtree WithCallback on the kSourceDir that uses checkfs.Directory to be More Permissive than 0444 +var callbackVerifyReadableDirectory = func(value interface{}) error { + return check.Directory(toString(value), directory.Options{Exists: true, MorePermissiveThan: 0444}) +} + +// toString uses figtree NewFlesh to return the ToString() value of the provided value argument +var toString = func(value interface{}) string { + switch v := value.(type) { + case string: + return v + case *string: + return *v + default: + flesh := figtree.NewFlesh(value) + f := fmt.Sprintf("%v", flesh.ToString()) + return f + } +} + +// capture assures that the d errors are not nil then runs terminate to write to os.Stderr +var capture = func(msg string, d ...error) { + if len(d) == 0 || (len(d) == 1 && d[0] == nil) { + return + } + terminate(os.Stderr, "[EXCUSE ME, BUT] %s\n\ncaptured error: %v\n", msg, d) +} + +// terminate can write to os.Stderr or os.Stdout with a fmt.Fprintf format as i and a variadic interface of e that gets rendered to d either in plain text or as JSON +var terminate = func(d io.Writer, i string, e ...interface{}) { + for _, f := range os.Args { + if strings.HasPrefix(f, "-json") { + mm := m{Message: fmt.Sprintf(i, e...)} + jb, err := json.MarshalIndent(mm, "", " ") + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "Error serializing json: %v\n", err) + _, _ = fmt.Fprintf(d, i, e...) + } else { + fmt.Println(string(jb)) + } + os.Exit(1) + } + } + _, _ = fmt.Fprintf(d, i, e...) + os.Exit(1) +} diff --git a/version.go b/version.go new file mode 100644 index 0000000..8b3bbd5 --- /dev/null +++ b/version.go @@ -0,0 +1,22 @@ +package main + +import ( + "embed" + "strings" +) + +//go:embed VERSION +var versionBytes embed.FS + +var currentVersion string + +func Version() string { + if len(currentVersion) == 0 { + versionBytes, err := versionBytes.ReadFile("VERSION") + if err != nil { + return "" + } + currentVersion = strings.TrimSpace(string(versionBytes)) + } + return currentVersion +} From 828b78afd6422ec68dbbd35173642e8be4a6a398 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Tue, 5 Aug 2025 23:35:11 -0400 Subject: [PATCH 02/11] Added AI integration --- VERSION | 2 +- ai.go | 95 ++++++++++++++++++++++++++++++++++++++++++ configure.go | 13 ++++++ const.go | 27 ++++++++++++ env.go | 13 ++++++ go.mod | 28 ++++++++++++- go.sum | 47 +++++++++++++++++++++ main.go | 115 ++++++++++++++++++++++++++++++++++++++++++++++++--- simplify.go | 49 ++++++++++++++++++++++ type.go | 34 +++++++++++---- var.go | 21 +++++++++- var_funcs.go | 2 +- 12 files changed, 427 insertions(+), 19 deletions(-) create mode 100644 ai.go diff --git a/VERSION b/VERSION index 13637f4..992977a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.0.3 \ No newline at end of file +v1.1.0 \ No newline at end of file diff --git a/ai.go b/ai.go new file mode 100644 index 0000000..482d500 --- /dev/null +++ b/ai.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strings" + + "github.com/teilomillet/gollm" +) + +func CanAI(figsK string) bool { + if len(figsK) == 0 { + return false + } + return *figs.Bool(kAiEnabled) && *figs.Bool(figsK) +} + +func Agent() AI { + capture("env FORCE_COLOR set", os.Setenv("FORCE_COLOR", "1")) + capture("env TERM set", os.Setenv("TERM", "xterm-256color")) + if aiPtr == nil { + provider, model, seed := *figs.String(kAiProvider), *figs.String(kAiModel), *figs.Int(kAiSeed) + maxTokens := *figs.Int(kAiMaxTokens) + var opts []gollm.ConfigOption + opts = append(opts, gollm.SetProvider(provider)) + opts = append(opts, gollm.SetModel(model)) + if seed > 1 { + opts = append(opts, gollm.SetSeed(seed)) + } + if maxTokens > 0 { + opts = append(opts, gollm.SetMaxTokens(maxTokens)) + } + switch provider { + case "ollama": + capture("unset OLLAMA_API_KEY env", os.Unsetenv("OLLAMA_API_KEY")) + opts = append(opts, gollm.SetTemperature(0.99)) + opts = append(opts, gollm.SetLogLevel(gollm.LogLevelError)) + default: + apiKey := *figs.String(kAiApiKey) + opts = append(opts, gollm.SetAPIKey(apiKey)) + } + llm, err := gollm.NewLLM(opts...) + if err != nil { + log.Fatal(err) + } + aiPtr = &agent{ + llm: llm, + } + } + return aiPtr +} + +func (a *agent) Ask(summary string, question ...string) (*Response, error) { + var ( + r = &Response{} + response = "" + err = errors.New("failed to ask agent for a response") + directives = []string{ + "Be concise and offer complete solutions", + "Act as Commander Data from the USS Starship Enterprise acting as an AI Agent assisting the user", + "Refer to the user as Commander", + "Speak as if you were on a Military Base as a member of the USS Starship Enterprise", + "Speak as if you are on duty with fellow crew mates", + "When replying to followup requests, build on your previous answer", + "When a mistake is identified by the user, use the full previous response to modify and return", + "Do not be afraid to offend and always give an honest answer in as few words as possible", + "Do not format the output in markdown, use just plain text to STDOUT through a redirect or pipe, thus no formatting at all.", + "Only reply in raw ASCII.", + } + + inputContext = strings.Clone(summary) + input = strings.Join(question, "\n") + + prompt = gollm.NewPrompt(input, + gollm.WithContext(inputContext), + gollm.WithDirectives(directives...), + ) + ) + response, err = a.llm.Generate(context.Background(), prompt) + if err != nil { + return nil, fmt.Errorf("failed to generate the response: %w", err) + } + + r.Response = strings.Clone(strings.TrimSpace(response)) + r.Request = strings.Clone(strings.TrimSpace(prompt.String())) + r.Context = inputContext + r.Directives = directives + r.agent = Agent() + r.prompt = prompt + + return r, nil +} diff --git a/configure.go b/configure.go index 8307463..eb6896b 100644 --- a/configure.go +++ b/configure.go @@ -32,6 +32,17 @@ func configure() { figs = figs.NewBool(kCompress, envIs(eAlwaysCompress), "Use gzip compression in output") figs = figs.NewBool(kVersion, false, "Display current version of summarize") figs = figs.NewBool(kDebug, false, "Enable debug mode") + figs = figs.NewBool(kShowExpanded, false, "Show expand menu") + + // ai mode + figs = figs.NewBool(kAiEnabled, envIs(eDisableAi) == false, "Enable AI Features") + figs = figs.NewString(kAiProvider, envVal(eAiProvider, dAiProvider), "AI Provider to use. (eg. ollama, openai, claude)") + figs = figs.NewString(kAiModel, envVal(eAiModel, dAiModel), "AI Model to use for query") + figs = figs.NewInt(kAiMaxTokens, envInt(eAiMaxTokens, dAiMaxTokens), "AI Max Tokens to use for query") + figs = figs.NewInt(kAiSeed, envInt(eAiSeed, dAiSeed), "AI Seed to use for query") + figs = figs.NewString(kAiApiKey, envVal(eAiApiKey, ""), "AI API Key to use for query (leave empty for ollama)") + figs = figs.NewBool(kAiAlwaysAsk, envIs(eAiAlwaysAsk), "AI Always ask a question about the summary file you're summarizing and include the response in the output") + figs = figs.NewBool(kAiAlwaysFollowUp, envIs(eAiAlwaysFollowUp), "Look until Ctrl+C by asking additional prompts for the chat conversation with the AI about the summary") // validators run internal figtree Assure funcs as arguments to validate against figs = figs.WithValidator(kSourceDir, figtree.AssureStringNotEmpty) @@ -39,6 +50,8 @@ func configure() { figs = figs.WithValidator(kFilename, figtree.AssureStringNotEmpty) figs = figs.WithValidator(kMaxFiles, figtree.AssureIntInRange(1, 17_369)) figs = figs.WithValidator(kMaxOutputSize, figtree.AssureInt64InRange(369, 369_369_369_369)) + figs = figs.WithValidator(kAiSeed, figtree.AssureIntInRange(-1, 369_369_369_369)) + figs = figs.WithValidator(kAiMaxTokens, figtree.AssureIntInRange(-1, 369_369_369_369)) // callbacks as figtree.CallbackAfterVerify run after the Validators above finish figs = figs.WithCallback(kSourceDir, figtree.CallbackAfterVerify, callbackVerifyReadableDirectory) diff --git a/const.go b/const.go index 55064d7..cfff8b3 100644 --- a/const.go +++ b/const.go @@ -28,6 +28,33 @@ const ( // eAlwaysCompress ENV string-as-bool (as "TRUE" or "true" for true) always sets -gz true in CLI argument parsing eAlwaysCompress string = "SUMMARIZE_ALWAYS_COMPRESS" + eDisableAi string = "SUMMARIZE_DISABLE_AI" + eAiProvider string = "SUMMARIZE_AI_PROVIDER" + eAiModel string = "SUMMARIZE_AI_MODEL" + eAiApiKey string = "SUMMARIZE_AI_API_KEY" + eAiMaxTokens string = "SUMMARIZE_AI_MAX_TOKENS" + eAiSeed string = "SUMMARIZE_AI_SEED" + eAiAlwaysAsk string = "SUMMARIZE_AI_ALWAYS_ASK" + eAiAlwaysFollowUp string = "SUMMARIZE_AI_ALWAYS_FOLLOWUP" + + dAiSeed int = -1 + dAiMaxTokens int = 3000 + dAiProvider string = "ollama" + dAiModel string = "mistral-small3.2:24b" + dAiAlwaysAsk bool = false + dAiAlwaysFollowUp bool = false + + kAiEnabled string = "ai" + kAiProvider string = "provider" + kAiModel string = "model" + kAiApiKey string = "api-key" + kAiMaxTokens string = "max-tokens" + kAiSeed string = "seed" + kAiAlwaysAsk string = "ask" + kAiAlwaysFollowUp string = "followup" + + kShowExpanded string = "expand" + // kSourceDir figtree fig string -d for the directory path to generate a summary of kSourceDir string = "d" diff --git a/env.go b/env.go index a55215a..22f211f 100644 --- a/env.go +++ b/env.go @@ -28,6 +28,19 @@ func envIs(name string) bool { return vb } +// envInt takes a name for os.Lookup with a fallback value to return an int +func envInt(name string, fallback int) int { + v, ok := os.LookupEnv(name) + if !ok { + return fallback + } + i, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return i +} + // addFromEnv takes a pointer to a slice of strings and a new ENV os.LookupEnv name to return the figtree ToList on the Flesh that sends the list into simplify before being returned func addFromEnv(e string, l *[]string) { v, ok := os.LookupEnv(e) diff --git a/go.mod b/go.mod index 4eb1111..b98a035 100644 --- a/go.mod +++ b/go.mod @@ -9,9 +9,33 @@ require ( ) require ( + github.com/Songmu/prompter v0.5.1 // indirect github.com/andreimerlescu/bump v1.0.3 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect + github.com/caarlos0/env/v11 v11.3.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-ini/ini v1.67.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/invopop/jsonschema v0.13.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkoukk/tiktoken-go v0.1.7 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/teilomillet/gollm v0.1.9 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sys v0.34.0 // indirect + golang.org/x/term v0.33.0 // indirect + golang.org/x/text v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b51abf5..9b38912 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/Songmu/prompter v0.5.1 h1:IAsttKsOZWSDw7bV1mtGn9TAmLFAjXbp9I/eYmUUogo= +github.com/Songmu/prompter v0.5.1/go.mod h1:CS3jEPD6h9IaLaG6afrl1orTgII9+uDWuw95dr6xHSw= github.com/andreimerlescu/bump v1.0.3 h1:RAmNPjS8lGhgiBhiTMEaRl1ydex7Z3YYuyiQohC+ShY= github.com/andreimerlescu/bump v1.0.3/go.mod h1:ud9Sqvt+zM0sBDhK3Dghq2hGTWrlVIvMqLAzpWQjIy0= github.com/andreimerlescu/checkfs v1.0.4 h1:pRXZGW1sfe+yXyWNUxmPC2IiX5yT3vF1V5O8PXulnFc= @@ -8,18 +10,63 @@ github.com/andreimerlescu/figtree/v2 v2.0.14 h1:pwDbHpfiAdSnaNnxyV2GpG1rG9cmGiHh github.com/andreimerlescu/figtree/v2 v2.0.14/go.mod h1:PymPGUzzP/UuxZ4mqC5JIrDZJIVcjZ3GMc/MC2GB6Ek= github.com/andreimerlescu/sema v1.0.0 h1:8ai/kqAci7QKUenAJWX13aYtWpjvD0CQW39CFzNIRQs= github.com/andreimerlescu/sema v1.0.0/go.mod h1:VCRQkKVknOKKPtAqvrNHL7hxxfoX5O7it2lWBzVxUs0= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= +github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= +github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/teilomillet/gollm v0.1.9 h1:1VwknVFVF7RvSv5ajqEYLhQAUi3X3PgmgPG1ipvmBe0= +github.com/teilomillet/gollm v0.1.9/go.mod h1:RBxoPOa1DfkqCy3ll68p6AplCvuRmiDkz0DwhE9J67s= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= +golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/main.go b/main.go index e51d628..b5b9ceb 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,25 @@ import ( func main() { configure() capture("figs loading environment", figs.Load()) + inc := *figs.List(kIncludeExt) + if len(inc) == 1 && inc[0] == "useExpanded" { + figs.StoreList(kIncludeExt, extendedDefaultInclude) + } + exc := *figs.List(kExcludeExt) + if len(exc) == 1 && exc[0] == "useExpanded" { + figs.StoreList(kExcludeExt, extendedDefaultExclude) + } + ski := *figs.List(kSkipContains) + if len(ski) == 1 && ski[0] == "useExpanded" { + figs.StoreList(kSkipContains, extendedDefaultAvoid) + } + if *figs.Bool(kShowExpanded) { + fmt.Println("Expanded:") + fmt.Printf("-%s=%s\n", kIncludeExt, strings.Join(*figs.List(kIncludeExt), ",")) + fmt.Printf("-%s=%s\n", kExcludeExt, strings.Join(*figs.List(kExcludeExt), ",")) + fmt.Printf("-%s=%s\n", kSkipContains, strings.Join(*figs.List(kSkipContains), ",")) + os.Exit(0) + } isDebug := *figs.Bool(kDebug) if *figs.Bool(kVersion) { fmt.Println(Version()) @@ -181,7 +200,7 @@ func main() { } maxFileSemaphore := sema.New(*figs.Int(kMaxFiles)) - resultsChan := make(chan result, *figs.Int(kMaxFiles)) + resultsChan := make(chan Result, *figs.Int(kMaxFiles)) writerWG := sync.WaitGroup{} writerWG.Add(1) go func() { @@ -201,12 +220,18 @@ func main() { buf.WriteString("length, then print the entire contents in your response with your updates to the ") buf.WriteString("specific components while retaining all existing functionality and maintaining comments ") buf.WriteString("within the code. \n\n") + if *figs.Bool(kAiEnabled) { + buf.WriteString("## AI \n\n") + buf.WriteString("The AI interacted with was: _REPLACE_ME_PROVIDER_ \n\n") + buf.WriteString("### Response \n\n") + buf.WriteString("_REPLACE_ME_QUESTION_ \n\n_REPLACE_ME_ANSWER_ \n\n") + } buf.WriteString("### Workspace\n\n") abs, err := filepath.Abs(srcDir) if err == nil { - buf.WriteString("" + abs + "\n\n") + buf.WriteString("`" + abs + "`\n\n") } else { - buf.WriteString("" + srcDir + "\n\n") + buf.WriteString("`" + srcDir + "`\n\n") } renderMu := &sync.Mutex{} @@ -225,6 +250,84 @@ func main() { buf.Write(in.Contents) renderMu.Unlock() } + isFinished := false + finished: + var response *Response + if !isFinished && CanAI(kAiAlwaysAsk) { + first := "We've summarized your workspace. What question would you like to ask the AI Agent?" + retry: + // question prompts the terminal for input text that acts as the question to the LLM with the summary as context + question := StringPrompt(first) + if IsSafeWord(question) { + isFinished = true + goto finished + } + + // copy the buf to output + output := buf.String() + + // replace the question + output = strings.ReplaceAll(output, "_REPLACE_ME_QUESTION_", question) + + // remove answer placeholder + output = strings.ReplaceAll(output, "_REPLACE_ME_ANSWER_ \n\n", "") + + // build new string + request := strings.Builder{} + // initial part of the prompt + request.WriteString("Agent, the Officer has requested the following question related to this project summary:\n\n") + // the text from the captured terminal session + request.WriteString(question) + + // PERFORM AI REQUEST + // reset output since data we need is inside of request + output = buf.String() + // replace the question placeholder with the actual question the user entered + output = strings.ReplaceAll(output, "_REPLACE_ME_QUESTION_", "Your question was: "+question) + // response uses the Agent() to Ask() the request using the output as the context + response, err = Agent().Ask(output, request.String()) + // WAIT FOR RESPONSE + output = strings.ReplaceAll(output, "_REPLACE_ME_ANSWER_", response.Response) + output = strings.ReplaceAll(output, "_REPLACE_ME_PROVIDER_", *figs.String(kAiProvider)) + buf.Reset() + buf.WriteString(output) + if CanAI(kAiAlwaysFollowUp) { + width := TermWidth() + inside := "--- ] AI RESPONSE [ ---" + var margin int = (width - len(inside)) / 2 + left := strings.Repeat(" ", int(margin)) + right := strings.Repeat(" ", int(margin)) + fmt.Printf("%s%s%s\n", left, inside, right) + fmt.Println(strings.Repeat("-", width)) + fmt.Println(response.Response) + fmt.Println("") + first = "Reply with '" + strings.Join(safeWords, ", ") + "' to end the conversation and include log in summary without exiting early because -followup is used!" + inside = "=== [ FOLLOWUP REQUEST ] ===" + left = strings.Repeat(" ", int(margin)) + right = strings.Repeat(" ", int(margin)) + fmt.Printf("%s%s%s\n", left, inside, right) + fmt.Println(strings.Repeat("-", width)) + goto retry + } + if *figs.Bool(kJson) { + m := M{ + Message: response.Response, + } + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + } + fmt.Println(string(b)) + os.Exit(1) + } + if *figs.Bool(kPrint) { + fmt.Println(response.Response) + os.Exit(0) + } + } + if isFinished { + + } shouldPrint := *figs.Bool(kPrint) canWrite := *figs.Bool(kWrite) @@ -251,7 +354,7 @@ func main() { if shouldPrint { if showJson { - r := final{ + r := Final{ Path: outputFileName, Size: int64(buf.Len()), Contents: buf.String(), @@ -352,7 +455,7 @@ func main() { content = []byte{} // clear memory after its written sb.WriteString("\n```\n\n") // close out the file footer seen.Add(filePath) - resultsChan <- result{ + resultsChan <- Result{ Path: filePath, Contents: sb.Bytes(), Size: int64(sb.Len()), @@ -379,7 +482,7 @@ func main() { // Print completion message if *figs.Bool(kJson) { - r := m{ + r := M{ Message: fmt.Sprintf("Summary generated: %s\n", filepath.Join(*figs.String(kOutputDir), *figs.String(kFilename)), ), diff --git a/simplify.go b/simplify.go index 2fd5b02..e224a5a 100644 --- a/simplify.go +++ b/simplify.go @@ -1,5 +1,14 @@ package main +import ( + "bufio" + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + // simplify takes a list of strings and reduces duplicates from the slice func simplify(t []string) []string { seen := make(map[string]bool) @@ -14,3 +23,43 @@ func simplify(t []string) []string { } return results } + +// StringPrompt asks for a string value using the label and returns the trimmed input. +func StringPrompt(label string) string { + var s string + r := bufio.NewReader(os.Stdin) + for { + _, _ = fmt.Fprint(os.Stderr, label+" \n") + s, _ = r.ReadString('\n') + if s != "" { + break + } + } + return strings.TrimSpace(s) +} + +func TermWidth() int { + width, _, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + width = 88 + } + return width +} + +func TermHeight() int { + _, height, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + height = 88 + } + return height +} + +func IsSafeWord(input string) bool { + input = strings.ToLower(strings.TrimSpace(input)) + for _, w := range safeWords { + if strings.EqualFold(w, input) { + return true + } + } + return false +} diff --git a/type.go b/type.go index 6ee5c69..2a2cf67 100644 --- a/type.go +++ b/type.go @@ -1,25 +1,45 @@ package main -import "sync" +import ( + "sync" + + "github.com/teilomillet/gollm" +) type ( + AI interface { + Ask(context string, question ...string) (*Response, error) + } + // agent represents a new ai llm interface + agent struct { + llm gollm.LLM + } + + Response struct { + agent AI + prompt *gollm.Prompt + Request string `json:"request"` + Directives []string `json:"directives"` + Context string `json:"context"` + Response string `json:"response"` + } - // result contains the scanned path in the kSourceDir that matched the conditions and shall be included in the final summary of kFilename - result struct { + // Result contains the scanned path in the kSourceDir that matched the conditions and shall be included in the Final summary of kFilename + Result struct { Path string `yaml:"path" json:"path"` Contents []byte `yaml:"contents" json:"contents"` Size int64 `yaml:"size" json:"size"` } - // final contains the rendered result of the matched path that gets written to kFilename - final struct { + // Final contains the rendered Result of the matched path that gets written to kFilename + Final struct { Path string `yaml:"path" json:"path"` Contents string `yaml:"contents" json:"contents"` Size int64 `yaml:"size" json:"size"` } - // m defines a Message that should be rendered to JSON - m struct { + // M defines a Message that should be rendered to JSON + M struct { Message string `json:"message"` } diff --git a/var.go b/var.go index 755c70b..49f11b7 100644 --- a/var.go +++ b/var.go @@ -8,8 +8,17 @@ var ( // figs is a figtree of fruit for configurable command line arguments that bear fruit figs figtree.Plant - // defaultExclude are the -exc list of extensions that will be skipped automatically + aiPtr *agent + + safeWords = []string{ + "stop", "quit", "exit", "done", + } + defaultExclude = []string{ + "useExpanded", + } + // defaultExclude are the -exc list of extensions that will be skipped automatically + extendedDefaultExclude = []string{ // Compressed archives "7z", "gz", "xz", "zst", "zstd", "bz", "bz2", "bzip2", "zip", "tar", "rar", "lz4", "lzma", "cab", "arj", @@ -75,11 +84,19 @@ var ( // defaultInclude are the -inc list of extensions that will be included in the summary defaultInclude = []string{ + "useExpanded", + } + + extendedDefaultInclude = []string{ "go", "ts", "tf", "sh", "py", "js", "Makefile", "mod", "Dockerfile", "dockerignore", "gitignore", "esconfigs", "md", } - // defaultAvoid are the -avoid list of substrings in file path names to avoid in the summary defaultAvoid = []string{ + "useExpanded", + } + + // extendedDefaultAvoid are the -avoid list of substrings in file path names to avoid in the summary + extendedDefaultAvoid = []string{ ".min.js", ".min.css", ".git/", ".svn/", ".vscode/", ".vs/", ".idea/", "logs/", "secrets/", ".venv/", "/site-packages", ".terraform/", "summaries/", "node_modules/", "/tmp", "tmp/", "logs/", } diff --git a/var_funcs.go b/var_funcs.go index 2be5495..69314e5 100644 --- a/var_funcs.go +++ b/var_funcs.go @@ -55,7 +55,7 @@ var capture = func(msg string, d ...error) { var terminate = func(d io.Writer, i string, e ...interface{}) { for _, f := range os.Args { if strings.HasPrefix(f, "-json") { - mm := m{Message: fmt.Sprintf(i, e...)} + mm := M{Message: fmt.Sprintf(i, e...)} jb, err := json.MarshalIndent(mm, "", " ") if err != nil { _, _ = fmt.Fprintf(os.Stderr, "Error serializing json: %v\n", err) From c8e1cc0d428578f1ff6ef19eed2fbc269b3a7878 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sun, 10 Aug 2025 22:09:41 -0400 Subject: [PATCH 03/11] Refactored app into smaller files Also removed -ai -ask -followup combo requirements in leu of using -chat. This launches a BubbleTea TUI Chat Session that has an LLM and Prompt preconfigured with the context of the new summary it just created. The chat log is then saved to the kOutputDir destination. Example usage includes: summarize -i "go,sh" -chat --- ai.go | 103 ++++--------------- chat.go | 284 +++++++++++++++++++++++++++++++++++++++++++++++++++ configure.go | 26 ++--- const.go | 45 ++++---- env.go | 48 +-------- go.mod | 30 +++++- go.sum | 74 +++++++++++--- main.go | 101 ++++-------------- reflect.go | 56 ++++++++++ simplify.go | 49 --------- type.go | 18 ---- var.go | 6 -- 12 files changed, 504 insertions(+), 336 deletions(-) create mode 100644 chat.go create mode 100644 reflect.go diff --git a/ai.go b/ai.go index 482d500..0017a6b 100644 --- a/ai.go +++ b/ai.go @@ -1,95 +1,36 @@ package main import ( - "context" - "errors" - "fmt" "log" "os" - "strings" "github.com/teilomillet/gollm" ) -func CanAI(figsK string) bool { - if len(figsK) == 0 { - return false +func NewAI() gollm.LLM { + provider, model, seed := *figs.String(kAiProvider), *figs.String(kAiModel), *figs.Int(kAiSeed) + maxTokens := *figs.Int(kAiMaxTokens) + var opts []gollm.ConfigOption + opts = append(opts, gollm.SetProvider(provider)) + opts = append(opts, gollm.SetModel(model)) + if seed > 1 { + opts = append(opts, gollm.SetSeed(seed)) } - return *figs.Bool(kAiEnabled) && *figs.Bool(figsK) -} - -func Agent() AI { - capture("env FORCE_COLOR set", os.Setenv("FORCE_COLOR", "1")) - capture("env TERM set", os.Setenv("TERM", "xterm-256color")) - if aiPtr == nil { - provider, model, seed := *figs.String(kAiProvider), *figs.String(kAiModel), *figs.Int(kAiSeed) - maxTokens := *figs.Int(kAiMaxTokens) - var opts []gollm.ConfigOption - opts = append(opts, gollm.SetProvider(provider)) - opts = append(opts, gollm.SetModel(model)) - if seed > 1 { - opts = append(opts, gollm.SetSeed(seed)) - } - if maxTokens > 0 { - opts = append(opts, gollm.SetMaxTokens(maxTokens)) - } - switch provider { - case "ollama": - capture("unset OLLAMA_API_KEY env", os.Unsetenv("OLLAMA_API_KEY")) - opts = append(opts, gollm.SetTemperature(0.99)) - opts = append(opts, gollm.SetLogLevel(gollm.LogLevelError)) - default: - apiKey := *figs.String(kAiApiKey) - opts = append(opts, gollm.SetAPIKey(apiKey)) - } - llm, err := gollm.NewLLM(opts...) - if err != nil { - log.Fatal(err) - } - aiPtr = &agent{ - llm: llm, - } + if maxTokens > 0 { + opts = append(opts, gollm.SetMaxTokens(maxTokens)) } - return aiPtr -} - -func (a *agent) Ask(summary string, question ...string) (*Response, error) { - var ( - r = &Response{} - response = "" - err = errors.New("failed to ask agent for a response") - directives = []string{ - "Be concise and offer complete solutions", - "Act as Commander Data from the USS Starship Enterprise acting as an AI Agent assisting the user", - "Refer to the user as Commander", - "Speak as if you were on a Military Base as a member of the USS Starship Enterprise", - "Speak as if you are on duty with fellow crew mates", - "When replying to followup requests, build on your previous answer", - "When a mistake is identified by the user, use the full previous response to modify and return", - "Do not be afraid to offend and always give an honest answer in as few words as possible", - "Do not format the output in markdown, use just plain text to STDOUT through a redirect or pipe, thus no formatting at all.", - "Only reply in raw ASCII.", - } - - inputContext = strings.Clone(summary) - input = strings.Join(question, "\n") - - prompt = gollm.NewPrompt(input, - gollm.WithContext(inputContext), - gollm.WithDirectives(directives...), - ) - ) - response, err = a.llm.Generate(context.Background(), prompt) + switch provider { + case "ollama": + capture("unset OLLAMA_API_KEY env", os.Unsetenv("OLLAMA_API_KEY")) + opts = append(opts, gollm.SetTemperature(0.99)) + opts = append(opts, gollm.SetLogLevel(gollm.LogLevelError)) + default: + apiKey := *figs.String(kAiApiKey) + opts = append(opts, gollm.SetAPIKey(apiKey)) + } + llm, err := gollm.NewLLM(opts...) if err != nil { - return nil, fmt.Errorf("failed to generate the response: %w", err) + log.Fatal(err) } - - r.Response = strings.Clone(strings.TrimSpace(response)) - r.Request = strings.Clone(strings.TrimSpace(prompt.String())) - r.Context = inputContext - r.Directives = directives - r.agent = Agent() - r.prompt = prompt - - return r, nil + return llm } diff --git a/chat.go b/chat.go new file mode 100644 index 0000000..5c42fff --- /dev/null +++ b/chat.go @@ -0,0 +1,284 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textarea" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/muesli/reflow/wordwrap" + "github.com/teilomillet/gollm" +) + +// --- STYLING --- +var ( + // Styles for chat messages + senderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")) // User (Purple) + botStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")) // AI (Cyan) + errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).Bold(true) // Error messages + + // A slight border for the chat viewport + viewportStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). // Gray + Padding(1) +) + +func StartChat(buf *bytes.Buffer) { + // Create and run the Bubble Tea program. + // tea.WithAltScreen() provides a full-window TUI experience. + // CORRECTED: Pass aiPtr.llm directly, not its address. + p := tea.NewProgram(initialModel(NewAI(), buf.String()), tea.WithAltScreen(), tea.WithMouseCellMotion()) + + finalModel, err := p.Run() + if err != nil { + log.Fatalf("āŒ Oh no, there's been an error: %v", err) + } + + if m, ok := finalModel.(model); ok && len(m.messages) > 1 { + // More than 1 message means there was a conversation (initial message + at least one more). + + // Create a timestamped filename. + timestamp := time.Now().Format("2006-01-02_15-04-05") + filename := fmt.Sprintf("chatlog_%s.txt", timestamp) + + // Join the styled messages into a single string for the log file. + logContent := strings.Join(m.messages, "\n") + + // Write the chat history to the file. + if writeErr := os.WriteFile(filepath.Join(*figs.String(kOutputDir), filename), []byte(logContent), 0644); writeErr != nil { + fmt.Printf("\nāŒ Could not save chat log: %v\n", writeErr) + } else { + fmt.Printf("\nšŸ“ Chat log saved to %s\n", filename) + } + } +} + +// --- BUBBLETEA MESSAGES --- +// We use custom messages to communicate between our async LLM calls and the UI. + +// aiResponseMsg is sent when the AI has successfully generated a response. +type aiResponseMsg string + +// errorMsg is sent when an error occurs during the AI call. +type errorMsg struct{ err error } + +// --- BUBBLETEA MODEL --- +// The model is the single source of truth for the state of your application. +type model struct { + // CORRECTED: The llm field is now the interface type, not a pointer to it. + llm gollm.LLM + viewport viewport.Model + textarea textarea.Model + messages []string + summary string + isGenerating bool + err error +} + +// initialModel creates the starting state of our application. +// CORRECTED: The llm parameter is now the interface type. +func initialModel(llm gollm.LLM, summary string) model { + // Configure the text area for user input. + ta := textarea.New() + ta.Placeholder = "Send a message... (press Enter to send, Esc to quit)" + ta.Focus() + ta.Prompt = "ā”ƒ " + ta.SetHeight(1) + // Remove the default behavior of Enter creating a new line. + ta.KeyMap.InsertNewline.SetEnabled(false) + + // The viewport is the scrolling area for the chat history. + vp := viewport.New(0, 0) // Width and height are set dynamically + + return model{ + llm: llm, + textarea: ta, + viewport: vp, + summary: summary, + messages: []string{"Hi! I'm your local AI assistant called Summarize. How can I help you?"}, + isGenerating: false, + err: nil, + } +} + +// generateResponseCmd is a Bubble Tea command that calls the LLM in a goroutine. +// This prevents the UI from blocking while waiting for the AI. +func (m model) generateResponseCmd() tea.Cmd { + return func() tea.Msg { + userInput := m.textarea.Value() + + var wc strings.Builder + breaker := "---ARM-GO-SUMMARIZE-BREAK-POINT---" + if len(m.messages) > 0 { + wc.WriteString("You are now continuing this conversation. This is the chat log: ") + for i := 0; i < len(m.messages); i++ { + v := m.messages[i] + x := fmt.Sprintf("line %d: %s\n", i+1, v) + wc.WriteString(x) + } + wc.WriteString("\n") + wc.WriteString("The summarized project is:\n") + parts := strings.Split(m.summary, breaker) + if len(parts) == 2 { + oldPrefix := strings.Clone(parts[0]) + oldSummary := strings.Clone(parts[1]) + newSummary := oldPrefix + wc.String() + oldSummary + m.summary = newSummary + wc.Reset() + } + wc.WriteString(m.summary) + wc.WriteString("\n") + } else { + wc.WriteString("Your name is Summarize in this engagement. This is a comprehensive one page contents of " + + "entire directory (recursively) of a specific subset of files by extension choice and a strings.Contains() avoid list" + + "that is used to generate the following summary.\n\n" + + "You are communicating with the user and shall refer to them as Commander. You are speaking to them in a " + + "golang bubbletea TUI chat terminal that is ") + wc.WriteString(strconv.Itoa(m.viewport.Width)) + wc.WriteString(" (int) width and ") + wc.WriteString(strconv.Itoa(m.viewport.Height)) + wc.WriteString(" (int) height with ") + wc.WriteString(strconv.Itoa(m.viewport.VisibleLineCount())) + wc.WriteString(" (int) visible lines in the viewport. Your responses should singularly fit in the terminal" + + "window knowing full well that the wordwrap.String(, m.viewport.Width)) is formatted in the bubble" + + "tea TUI. \n\n" + + "The user expects you to be professional and keep focused on the language that you detect from the summary.\n" + + "Be prepared to answer questions about security, best practices, and security concerns that you have over" + + "the code. However, do not get distracted. Always follow the lead of the DevOps engineer. Do not be afraid to" + + "offend. Your brutal honesty is welcome here and iron sharpens iron. Here is the summary now:\n" + + breaker + "\n") + oldSummary := strings.Clone(m.summary) + newSummary := oldSummary + wc.String() + m.summary = newSummary + wc.Reset() + wc.WriteString(m.summary) + wc.WriteString("\n") + } + + var ( + response = "" + err = errors.New("failed to ask agent for a response") + prompt = gollm.NewPrompt(userInput, + gollm.WithContext(wc.String()), + gollm.WithDirectives("Only reply in raw ASCII.", + "Be concise and offer complete solutions", + "Act as Commander Data from the USS Starship Enterprise acting as an AI Agent assisting the user", + "Refer to the user as Commander", + "Speak as if you were on a Military Base as a member of the USS Starship Enterprise", + "Speak as if you are on duty with fellow crew mates", + "When replying to followup requests, build on your previous answer", + "When a mistake is identified by the user, use the full previous response to modify and return", + "Do not be afraid to offend and always give an honest answer in as few words as possible", + "Do not format the output in markdown, use just plain text to STDOUT through a redirect or pipe, thus no formatting at all.", + ), + ) + ) + response, err = m.llm.Generate(context.Background(), prompt) + if err != nil { + return errorMsg{err} // On error, return an error message. + } + response = response + "\n\n" + + return aiResponseMsg(response) // On success, return the AI's response. + } +} + +// --- BUBBLETEA LIFECYCLE --- + +// Init is called once when the program starts. It can return an initial command. +func (m model) Init() tea.Cmd { + return textarea.Blink // Start with a blinking cursor in the textarea. +} + +// Update is the core of the application. It's called whenever a message (event) occurs. +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var ( + taCmd tea.Cmd + vpCmd tea.Cmd + ) + + // Handle updates for the textarea and viewport components. + m.textarea, taCmd = m.textarea.Update(msg) + m.viewport, vpCmd = m.viewport.Update(msg) + + switch msg := msg.(type) { + // Handle key presses + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, tea.Quit + case tea.KeyEnter: + // Don't send if the AI is already working or input is empty. + if m.isGenerating || m.textarea.Value() == "" { + return m, nil + } + + // Add the user's message to the history and set the generating flag. + m.messages = append(m.messages, senderStyle.Render("You: ")+m.textarea.Value()) + m.isGenerating = true + m.err = nil // Clear any previous error. + + // Create the command to call the LLM and reset the input. + cmd := m.generateResponseCmd() + m.textarea.Reset() + m.viewport.SetContent(wordwrap.String(strings.Join(m.messages, "\n"), m.viewport.Width)) + m.viewport.GotoBottom() // Scroll to the latest message. + + return m, cmd + } + + // Handle window resizing + case tea.WindowSizeMsg: + // Adjust the layout to the new window size. + viewportStyle.Width(msg.Width - 2) // Subtract border width + viewportStyle.Height(msg.Height - 4) // Subtract textarea, help text, and border + m.viewport.Width = msg.Width - 2 + m.viewport.Height = msg.Height - 4 + m.textarea.SetWidth(msg.Width) + m.viewport.SetContent(wordwrap.String(strings.Join(m.messages, "\n"), m.viewport.Width)) // Re-render content + + // Handle the AI's response + case aiResponseMsg: + m.isGenerating = false + m.messages = append(m.messages, botStyle.Render("Summarize AI: ")+string(msg)) + m.viewport.SetContent(wordwrap.String(strings.Join(m.messages, "\n"), m.viewport.Width)) + m.viewport.GotoBottom() + + // Handle any errors from the AI call + case errorMsg: + m.isGenerating = false + m.err = msg.err + } + + return m, tea.Batch(taCmd, vpCmd) // Return any commands from the components. +} + +// View renders the UI. It's called after every Update. +func (m model) View() string { + var bottomLine string + if m.isGenerating { + bottomLine = "šŸ¤” Thinking..." + } else if m.err != nil { + bottomLine = errorStyle.Render(fmt.Sprintf("Error: %v", m.err)) + } else { + bottomLine = m.textarea.View() + } + + // Join the viewport and the bottom line (textarea or status) vertically. + return lipgloss.JoinVertical( + lipgloss.Left, + viewportStyle.Render(m.viewport.View()), + bottomLine, + ) +} diff --git a/configure.go b/configure.go index eb6896b..3f839a0 100644 --- a/configure.go +++ b/configure.go @@ -5,6 +5,7 @@ import ( "path/filepath" "github.com/andreimerlescu/figtree/v2" + "github.com/andreimerlescu/goenv/env" ) // init creates a new figtree with options to use CONFIG_FILE as a way of reading a YAML file while ignoring the env @@ -13,7 +14,7 @@ func configure() { figs = figtree.With(figtree.Options{ Harvest: 9, IgnoreEnvironment: true, - ConfigFile: envVal(eConfigFile, "./config.yaml"), + ConfigFile: env.String(eConfigFile, "./config.yaml"), }) // properties define new fig fruits on the figtree @@ -26,23 +27,22 @@ func configure() { figs = figs.NewInt(kMaxFiles, 369, "Maximum number of files to process concurrently") figs = figs.NewInt64(kMaxOutputSize, 1_776_369, "Maximum file size of output file") figs = figs.NewBool(kDotFiles, false, "Any path that is considered a dotfile can be included by setting this to true") - figs = figs.NewBool(kPrint, envIs(eAlwaysPrint), "Print generated file contents to STDOUT") - figs = figs.NewBool(kWrite, envIs(eAlwaysWrite), "Write generated contents to file") - figs = figs.NewBool(kJson, envIs(eAlwaysJson), "Enable JSON formatting") - figs = figs.NewBool(kCompress, envIs(eAlwaysCompress), "Use gzip compression in output") + figs = figs.NewBool(kPrint, env.Bool(eAlwaysPrint, false), "Print generated file contents to STDOUT") + figs = figs.NewBool(kWrite, env.Bool(eAlwaysWrite, false), "Write generated contents to file") + figs = figs.NewBool(kJson, env.Bool(eAlwaysJson, false), "Enable JSON formatting") + figs = figs.NewBool(kCompress, env.Bool(eAlwaysCompress, false), "Use gzip compression in output") figs = figs.NewBool(kVersion, false, "Display current version of summarize") figs = figs.NewBool(kDebug, false, "Enable debug mode") figs = figs.NewBool(kShowExpanded, false, "Show expand menu") + figs = figs.NewBool(kChat, false, "AI chat session with transcript based on new summary information in summary after") // ai mode - figs = figs.NewBool(kAiEnabled, envIs(eDisableAi) == false, "Enable AI Features") - figs = figs.NewString(kAiProvider, envVal(eAiProvider, dAiProvider), "AI Provider to use. (eg. ollama, openai, claude)") - figs = figs.NewString(kAiModel, envVal(eAiModel, dAiModel), "AI Model to use for query") - figs = figs.NewInt(kAiMaxTokens, envInt(eAiMaxTokens, dAiMaxTokens), "AI Max Tokens to use for query") - figs = figs.NewInt(kAiSeed, envInt(eAiSeed, dAiSeed), "AI Seed to use for query") - figs = figs.NewString(kAiApiKey, envVal(eAiApiKey, ""), "AI API Key to use for query (leave empty for ollama)") - figs = figs.NewBool(kAiAlwaysAsk, envIs(eAiAlwaysAsk), "AI Always ask a question about the summary file you're summarizing and include the response in the output") - figs = figs.NewBool(kAiAlwaysFollowUp, envIs(eAiAlwaysFollowUp), "Look until Ctrl+C by asking additional prompts for the chat conversation with the AI about the summary") + figs = figs.NewBool(kAiEnabled, env.Bool(eDisableAi, false) == false, "Enable AI Features") + figs = figs.NewString(kAiProvider, env.String(eAiProvider, dAiProvider), "AI Provider to use. (eg. ollama, openai, claude)") + figs = figs.NewString(kAiModel, env.String(eAiModel, dAiModel), "AI Model to use for query") + figs = figs.NewInt(kAiMaxTokens, env.Int(eAiMaxTokens, dAiMaxTokens), "AI Max Tokens to use for query") + figs = figs.NewInt(kAiSeed, env.Int(eAiSeed, dAiSeed), "AI Seed to use for query") + figs = figs.NewString(kAiApiKey, env.String(eAiApiKey, ""), "AI API Key to use for query (leave empty for ollama)") // validators run internal figtree Assure funcs as arguments to validate against figs = figs.WithValidator(kSourceDir, figtree.AssureStringNotEmpty) diff --git a/const.go b/const.go index cfff8b3..93f7f2c 100644 --- a/const.go +++ b/const.go @@ -28,33 +28,30 @@ const ( // eAlwaysCompress ENV string-as-bool (as "TRUE" or "true" for true) always sets -gz true in CLI argument parsing eAlwaysCompress string = "SUMMARIZE_ALWAYS_COMPRESS" - eDisableAi string = "SUMMARIZE_DISABLE_AI" - eAiProvider string = "SUMMARIZE_AI_PROVIDER" - eAiModel string = "SUMMARIZE_AI_MODEL" - eAiApiKey string = "SUMMARIZE_AI_API_KEY" - eAiMaxTokens string = "SUMMARIZE_AI_MAX_TOKENS" - eAiSeed string = "SUMMARIZE_AI_SEED" - eAiAlwaysAsk string = "SUMMARIZE_AI_ALWAYS_ASK" - eAiAlwaysFollowUp string = "SUMMARIZE_AI_ALWAYS_FOLLOWUP" - - dAiSeed int = -1 - dAiMaxTokens int = 3000 - dAiProvider string = "ollama" - dAiModel string = "mistral-small3.2:24b" - dAiAlwaysAsk bool = false - dAiAlwaysFollowUp bool = false - - kAiEnabled string = "ai" - kAiProvider string = "provider" - kAiModel string = "model" - kAiApiKey string = "api-key" - kAiMaxTokens string = "max-tokens" - kAiSeed string = "seed" - kAiAlwaysAsk string = "ask" - kAiAlwaysFollowUp string = "followup" + eDisableAi string = "SUMMARIZE_DISABLE_AI" + eAiProvider string = "SUMMARIZE_AI_PROVIDER" + eAiModel string = "SUMMARIZE_AI_MODEL" + eAiApiKey string = "SUMMARIZE_AI_API_KEY" + eAiMaxTokens string = "SUMMARIZE_AI_MAX_TOKENS" + eAiSeed string = "SUMMARIZE_AI_SEED" + + dAiSeed int = -1 + dAiMaxTokens int = 3000 + dAiProvider string = "ollama" + dAiModel string = "gpt-oss:20b" + // dAiModel string = "mistral-small3.2:24b" + + kAiEnabled string = "ai" + kAiProvider string = "provider" + kAiModel string = "model" + kAiApiKey string = "api-key" + kAiMaxTokens string = "max-tokens" + kAiSeed string = "seed" kShowExpanded string = "expand" + kChat string = "chat" + // kSourceDir figtree fig string -d for the directory path to generate a summary of kSourceDir string = "d" diff --git a/env.go b/env.go index 22f211f..4191642 100644 --- a/env.go +++ b/env.go @@ -1,55 +1,13 @@ package main import ( - "github.com/andreimerlescu/figtree/v2" - "os" - "strconv" + "github.com/andreimerlescu/goenv/env" ) -// envVal takes a name for os.LookupEnv with a fallback to return a string -func envVal(name, fallback string) string { - v, ok := os.LookupEnv(name) - if !ok { - return fallback - } - return v -} - -// envIs takes a name for os.LookupEnv with a fallback of false to return a bool -func envIs(name string) bool { - v, ok := os.LookupEnv(name) - if !ok { - return false - } - vb, err := strconv.ParseBool(v) - if err != nil { - return false - } - return vb -} - -// envInt takes a name for os.Lookup with a fallback value to return an int -func envInt(name string, fallback int) int { - v, ok := os.LookupEnv(name) - if !ok { - return fallback - } - i, err := strconv.Atoi(v) - if err != nil { - return fallback - } - return i -} - // addFromEnv takes a pointer to a slice of strings and a new ENV os.LookupEnv name to return the figtree ToList on the Flesh that sends the list into simplify before being returned func addFromEnv(e string, l *[]string) { - v, ok := os.LookupEnv(e) - if ok { - flesh := figtree.NewFlesh(v) - maybeAdd := flesh.ToList() - for _, entry := range maybeAdd { - *l = append(*l, entry) - } + for _, entry := range env.List(e, []string{}) { + *l = append(*l, entry) } *l = simplify(*l) } diff --git a/go.mod b/go.mod index b98a035..e4b19aa 100644 --- a/go.mod +++ b/go.mod @@ -5,17 +5,29 @@ go 1.24.5 require ( github.com/andreimerlescu/checkfs v1.0.4 github.com/andreimerlescu/figtree/v2 v2.0.14 + github.com/andreimerlescu/goenv v0.0.0-20250810022511-93d6119cf4da github.com/andreimerlescu/sema v1.0.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.6 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/muesli/reflow v0.3.0 + github.com/teilomillet/gollm v0.1.9 ) require ( - github.com/Songmu/prompter v0.5.1 // indirect - github.com/andreimerlescu/bump v1.0.3 // indirect + github.com/andreimerlescu/env v0.0.1 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/caarlos0/env/v11 v11.3.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect github.com/go-ini/ini v1.67.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -24,18 +36,26 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/invopop/jsonschema v0.13.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pkoukk/tiktoken-go v0.1.7 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.10.0 // indirect - github.com/teilomillet/gollm v0.1.9 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/crypto v0.40.0 // indirect golang.org/x/net v0.42.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.27.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9b38912..a3ed763 100644 --- a/go.sum +++ b/go.sum @@ -1,29 +1,53 @@ -github.com/Songmu/prompter v0.5.1 h1:IAsttKsOZWSDw7bV1mtGn9TAmLFAjXbp9I/eYmUUogo= -github.com/Songmu/prompter v0.5.1/go.mod h1:CS3jEPD6h9IaLaG6afrl1orTgII9+uDWuw95dr6xHSw= -github.com/andreimerlescu/bump v1.0.3 h1:RAmNPjS8lGhgiBhiTMEaRl1ydex7Z3YYuyiQohC+ShY= -github.com/andreimerlescu/bump v1.0.3/go.mod h1:ud9Sqvt+zM0sBDhK3Dghq2hGTWrlVIvMqLAzpWQjIy0= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/andreimerlescu/checkfs v1.0.4 h1:pRXZGW1sfe+yXyWNUxmPC2IiX5yT3vF1V5O8PXulnFc= github.com/andreimerlescu/checkfs v1.0.4/go.mod h1:ADaqjiRJf3gmyENLS3v9bJIaEH00IOeM48cXxVwy1JY= -github.com/andreimerlescu/figtree/v2 v2.0.10 h1:UWKBVpwa4lI+mp3VxUy7MzkzaigROZd4zOGJrarNpv0= -github.com/andreimerlescu/figtree/v2 v2.0.10/go.mod h1:PymPGUzzP/UuxZ4mqC5JIrDZJIVcjZ3GMc/MC2GB6Ek= +github.com/andreimerlescu/env v0.0.1 h1:R3DTdLyRSuKLTHvlmTaHLloVpTnrHrX/JGbjM/28cl8= +github.com/andreimerlescu/env v0.0.1/go.mod h1:Jzu9qHPCv7c0o/rBmagt6z6Kf49K3ba0WIpi0D2YYUg= github.com/andreimerlescu/figtree/v2 v2.0.14 h1:pwDbHpfiAdSnaNnxyV2GpG1rG9cmGiHhjXOvBEoVj2w= github.com/andreimerlescu/figtree/v2 v2.0.14/go.mod h1:PymPGUzzP/UuxZ4mqC5JIrDZJIVcjZ3GMc/MC2GB6Ek= +github.com/andreimerlescu/goenv v0.0.0-20250810022511-93d6119cf4da h1:uTPeoV4F7+gFcxLCgyqJFHvOhfaKVahp2BALC5zw0eg= +github.com/andreimerlescu/goenv v0.0.0-20250810022511-93d6119cf4da/go.mod h1:ehn80sBXiQAH4v3FUUVze1Oh23OeHWIk/gYwxTl6WKw= github.com/andreimerlescu/sema v1.0.0 h1:8ai/kqAci7QKUenAJWX13aYtWpjvD0CQW39CFzNIRQs= github.com/andreimerlescu/sema v1.0.0/go.mod h1:VCRQkKVknOKKPtAqvrNHL7hxxfoX5O7it2lWBzVxUs0= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -36,14 +60,33 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkoukk/tiktoken-go v0.1.7 h1:qOBHXX4PHtvIvmOtyg1EeKlwFRiMKAcoMp4Q+bLQDmw= github.com/pkoukk/tiktoken-go v0.1.7/go.mod h1:9NiV+i9mJKGj1rYOT+njbv+ZwA/zJxYdewGl6qVatpg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= @@ -52,19 +95,22 @@ github.com/teilomillet/gollm v0.1.9 h1:1VwknVFVF7RvSv5ajqEYLhQAUi3X3PgmgPG1ipvmB github.com/teilomillet/gollm v0.1.9/go.mod h1:RBxoPOa1DfkqCy3ll68p6AplCvuRmiDkz0DwhE9J67s= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= +golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/main.go b/main.go index b5b9ceb..3d446d8 100644 --- a/main.go +++ b/main.go @@ -21,18 +21,23 @@ import ( func main() { configure() capture("figs loading environment", figs.Load()) + inc := *figs.List(kIncludeExt) if len(inc) == 1 && inc[0] == "useExpanded" { figs.StoreList(kIncludeExt, extendedDefaultInclude) } + fmt.Println(strings.Join(inc, ", ")) exc := *figs.List(kExcludeExt) if len(exc) == 1 && exc[0] == "useExpanded" { figs.StoreList(kExcludeExt, extendedDefaultExclude) } + fmt.Println(strings.Join(exc, ", ")) ski := *figs.List(kSkipContains) if len(ski) == 1 && ski[0] == "useExpanded" { figs.StoreList(kSkipContains, extendedDefaultAvoid) } + fmt.Println(strings.Join(ski, ", ")) + if *figs.Bool(kShowExpanded) { fmt.Println("Expanded:") fmt.Printf("-%s=%s\n", kIncludeExt, strings.Join(*figs.List(kIncludeExt), ",")) @@ -41,6 +46,7 @@ func main() { os.Exit(0) } isDebug := *figs.Bool(kDebug) + if *figs.Bool(kVersion) { fmt.Println(Version()) os.Exit(0) @@ -220,12 +226,6 @@ func main() { buf.WriteString("length, then print the entire contents in your response with your updates to the ") buf.WriteString("specific components while retaining all existing functionality and maintaining comments ") buf.WriteString("within the code. \n\n") - if *figs.Bool(kAiEnabled) { - buf.WriteString("## AI \n\n") - buf.WriteString("The AI interacted with was: _REPLACE_ME_PROVIDER_ \n\n") - buf.WriteString("### Response \n\n") - buf.WriteString("_REPLACE_ME_QUESTION_ \n\n_REPLACE_ME_ANSWER_ \n\n") - } buf.WriteString("### Workspace\n\n") abs, err := filepath.Abs(srcDir) if err == nil { @@ -250,83 +250,22 @@ func main() { buf.Write(in.Contents) renderMu.Unlock() } - isFinished := false - finished: - var response *Response - if !isFinished && CanAI(kAiAlwaysAsk) { - first := "We've summarized your workspace. What question would you like to ask the AI Agent?" - retry: - // question prompts the terminal for input text that acts as the question to the LLM with the summary as context - question := StringPrompt(first) - if IsSafeWord(question) { - isFinished = true - goto finished - } - // copy the buf to output - output := buf.String() - - // replace the question - output = strings.ReplaceAll(output, "_REPLACE_ME_QUESTION_", question) - - // remove answer placeholder - output = strings.ReplaceAll(output, "_REPLACE_ME_ANSWER_ \n\n", "") - - // build new string - request := strings.Builder{} - // initial part of the prompt - request.WriteString("Agent, the Officer has requested the following question related to this project summary:\n\n") - // the text from the captured terminal session - request.WriteString(question) - - // PERFORM AI REQUEST - // reset output since data we need is inside of request - output = buf.String() - // replace the question placeholder with the actual question the user entered - output = strings.ReplaceAll(output, "_REPLACE_ME_QUESTION_", "Your question was: "+question) - // response uses the Agent() to Ask() the request using the output as the context - response, err = Agent().Ask(output, request.String()) - // WAIT FOR RESPONSE - output = strings.ReplaceAll(output, "_REPLACE_ME_ANSWER_", response.Response) - output = strings.ReplaceAll(output, "_REPLACE_ME_PROVIDER_", *figs.String(kAiProvider)) - buf.Reset() - buf.WriteString(output) - if CanAI(kAiAlwaysFollowUp) { - width := TermWidth() - inside := "--- ] AI RESPONSE [ ---" - var margin int = (width - len(inside)) / 2 - left := strings.Repeat(" ", int(margin)) - right := strings.Repeat(" ", int(margin)) - fmt.Printf("%s%s%s\n", left, inside, right) - fmt.Println(strings.Repeat("-", width)) - fmt.Println(response.Response) - fmt.Println("") - first = "Reply with '" + strings.Join(safeWords, ", ") + "' to end the conversation and include log in summary without exiting early because -followup is used!" - inside = "=== [ FOLLOWUP REQUEST ] ===" - left = strings.Repeat(" ", int(margin)) - right = strings.Repeat(" ", int(margin)) - fmt.Printf("%s%s%s\n", left, inside, right) - fmt.Println(strings.Repeat("-", width)) - goto retry - } - if *figs.Bool(kJson) { - m := M{ - Message: response.Response, - } - b, err := json.MarshalIndent(m, "", " ") - if err != nil { - _, _ = fmt.Fprintln(os.Stderr, err) - } - fmt.Println(string(b)) - os.Exit(1) + if *figs.Bool(kChat) { + StartChat(&buf) + path := latestSummaryFile() + contents, err := os.ReadFile(path) + if err != nil { + old := buf.String() + buf.Reset() + buf.WriteString("## Chat Log \n\n") + body := string(contents) + body = strings.ReplaceAll(body, "You: ", "\n### ") + buf.WriteString(body) + buf.WriteString("\n\n") + buf.WriteString("## Summary \n\n") + buf.WriteString(old) } - if *figs.Bool(kPrint) { - fmt.Println(response.Response) - os.Exit(0) - } - } - if isFinished { - } shouldPrint := *figs.Bool(kPrint) diff --git a/reflect.go b/reflect.go new file mode 100644 index 0000000..bcaab56 --- /dev/null +++ b/reflect.go @@ -0,0 +1,56 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +func latestSummaryFile() string { + entries, err := os.ReadDir(kOutputDir) + if err != nil { + if *figs.Bool(kDebug) { + _, _ = fmt.Fprintf(os.Stderr, "Error reading directory %s: %v\n", kOutputDir, err) + } + return "" + } + + var latestFile string + var latestModTime time.Time + + for _, entry := range entries { + // Skip directories + if entry.IsDir() { + continue + } + + filename := entry.Name() + // Check if file matches pattern: starts with "summary." and ends with ".md" + if strings.HasPrefix(filename, "summary.") && strings.HasSuffix(filename, ".md") { + fullPath := filepath.Join(kOutputDir, filename) + + fileInfo, err := entry.Info() + if err != nil { + if *figs.Bool(kDebug) { + _, _ = fmt.Fprintf(os.Stderr, "Error getting file info for %s: %v\n", fullPath, err) + } + continue + } + + modTime := fileInfo.ModTime() + // If this is the first matching file or it's newer than the current latest + if latestFile == "" || modTime.After(latestModTime) { + latestFile = fullPath + latestModTime = modTime + } + } + } + + if latestFile == "" && *figs.Bool(kDebug) { + _, _ = fmt.Fprintf(os.Stderr, "No summary files found in directory %s\n", kOutputDir) + } + + return latestFile +} diff --git a/simplify.go b/simplify.go index e224a5a..2fd5b02 100644 --- a/simplify.go +++ b/simplify.go @@ -1,14 +1,5 @@ package main -import ( - "bufio" - "fmt" - "os" - "strings" - - "golang.org/x/term" -) - // simplify takes a list of strings and reduces duplicates from the slice func simplify(t []string) []string { seen := make(map[string]bool) @@ -23,43 +14,3 @@ func simplify(t []string) []string { } return results } - -// StringPrompt asks for a string value using the label and returns the trimmed input. -func StringPrompt(label string) string { - var s string - r := bufio.NewReader(os.Stdin) - for { - _, _ = fmt.Fprint(os.Stderr, label+" \n") - s, _ = r.ReadString('\n') - if s != "" { - break - } - } - return strings.TrimSpace(s) -} - -func TermWidth() int { - width, _, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - width = 88 - } - return width -} - -func TermHeight() int { - _, height, err := term.GetSize(int(os.Stdout.Fd())) - if err != nil { - height = 88 - } - return height -} - -func IsSafeWord(input string) bool { - input = strings.ToLower(strings.TrimSpace(input)) - for _, w := range safeWords { - if strings.EqualFold(w, input) { - return true - } - } - return false -} diff --git a/type.go b/type.go index 2a2cf67..f9a8f93 100644 --- a/type.go +++ b/type.go @@ -2,27 +2,9 @@ package main import ( "sync" - - "github.com/teilomillet/gollm" ) type ( - AI interface { - Ask(context string, question ...string) (*Response, error) - } - // agent represents a new ai llm interface - agent struct { - llm gollm.LLM - } - - Response struct { - agent AI - prompt *gollm.Prompt - Request string `json:"request"` - Directives []string `json:"directives"` - Context string `json:"context"` - Response string `json:"response"` - } // Result contains the scanned path in the kSourceDir that matched the conditions and shall be included in the Final summary of kFilename Result struct { diff --git a/var.go b/var.go index 49f11b7..a6b83ba 100644 --- a/var.go +++ b/var.go @@ -8,12 +8,6 @@ var ( // figs is a figtree of fruit for configurable command line arguments that bear fruit figs figtree.Plant - aiPtr *agent - - safeWords = []string{ - "stop", "quit", "exit", "done", - } - defaultExclude = []string{ "useExpanded", } From 682c76fd949f3e5070e39f9b471d94e88754f98e Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sun, 10 Aug 2025 22:24:49 -0400 Subject: [PATCH 04/11] Version bump for unchartered terriority --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 992977a..cb0d662 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.1.0 \ No newline at end of file +v1.1.0-beta.0 From 283cdf0b6058bcf1109addd035caf2616c561ac4 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sun, 10 Aug 2025 22:35:10 -0400 Subject: [PATCH 05/11] Update simplify.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- simplify.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/simplify.go b/simplify.go index 2fd5b02..995f93b 100644 --- a/simplify.go +++ b/simplify.go @@ -3,13 +3,11 @@ package main // simplify takes a list of strings and reduces duplicates from the slice func simplify(t []string) []string { seen := make(map[string]bool) + results := make([]string, 0, len(t)) for _, v := range t { - seen[v] = true - } - results := make([]string, len(t)) - for i, v := range t { - if seen[v] { - results[i] = v + if !seen[v] { + seen[v] = true + results = append(results, v) } } return results From 8b00e750a32e54185731fbfc710bb31112946c45 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sun, 10 Aug 2025 22:35:48 -0400 Subject: [PATCH 06/11] Update chat.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- chat.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/chat.go b/chat.go index 5c42fff..4cd51d1 100644 --- a/chat.go +++ b/chat.go @@ -151,8 +151,8 @@ func (m model) generateResponseCmd() tea.Cmd { wc.WriteString(" (int) height with ") wc.WriteString(strconv.Itoa(m.viewport.VisibleLineCount())) wc.WriteString(" (int) visible lines in the viewport. Your responses should singularly fit in the terminal" + - "window knowing full well that the wordwrap.String(, m.viewport.Width)) is formatted in the bubble" + - "tea TUI. \n\n" + + "window. Be aware that your response will be formatted using wordwrap.String(, m.viewport.Width) in the Bubbletea TUI, so ensure your message fits within the viewport width. " + + "\n\n" + "The user expects you to be professional and keep focused on the language that you detect from the summary.\n" + "Be prepared to answer questions about security, best practices, and security concerns that you have over" + "the code. However, do not get distracted. Always follow the lead of the DevOps engineer. Do not be afraid to" + From a52d643d6030b26ff09340cd2df318eb5dc31258 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sun, 10 Aug 2025 22:36:24 -0400 Subject: [PATCH 07/11] Update main.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 3d446d8..c8d7755 100644 --- a/main.go +++ b/main.go @@ -255,7 +255,7 @@ func main() { StartChat(&buf) path := latestSummaryFile() contents, err := os.ReadFile(path) - if err != nil { + if err == nil { old := buf.String() buf.Reset() buf.WriteString("## Chat Log \n\n") From 8c09e6614a1b813fdae2e158dc1a5cabf29c93f1 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sat, 16 Aug 2025 11:06:01 -0400 Subject: [PATCH 08/11] Added AI options --- ai.go | 8 ++++++++ chat.go | 49 ++++++++++++++++++++++++++++++++++++++----------- configure.go | 6 +++++- const.go | 40 ++++++++++++++++++++++++++-------------- main.go | 2 +- reflect.go | 4 ++-- 6 files changed, 80 insertions(+), 29 deletions(-) diff --git a/ai.go b/ai.go index 0017a6b..26683d3 100644 --- a/ai.go +++ b/ai.go @@ -3,6 +3,7 @@ package main import ( "log" "os" + "time" "github.com/teilomillet/gollm" ) @@ -19,6 +20,13 @@ func NewAI() gollm.LLM { if maxTokens > 0 { opts = append(opts, gollm.SetMaxTokens(maxTokens)) } + opts = append(opts, gollm.SetMemory(*figs.Int(kMemory))) + opts = append(opts, gollm.SetEnableCaching(*figs.Bool(kAiCachingEnabled))) + timeout := *figs.UnitDuration(kAiTimeout) + if timeout < time.Second { + panic("incorrect timeout value") + } + opts = append(opts, gollm.SetTimeout(*figs.UnitDuration(kAiTimeout))) switch provider { case "ollama": capture("unset OLLAMA_API_KEY env", os.Unsetenv("OLLAMA_API_KEY")) diff --git a/chat.go b/chat.go index 5c42fff..dfbe6f6 100644 --- a/chat.go +++ b/chat.go @@ -50,13 +50,18 @@ func StartChat(buf *bytes.Buffer) { // Create a timestamped filename. timestamp := time.Now().Format("2006-01-02_15-04-05") - filename := fmt.Sprintf("chatlog_%s.txt", timestamp) - - // Join the styled messages into a single string for the log file. - logContent := strings.Join(m.messages, "\n") + filename := fmt.Sprintf("chatlog_%s.md", timestamp) + + var output bytes.Buffer + output.WriteString("# Summarize Chat Log " + timestamp + "\n\n") + for i := 0; i < len(m.messages); i++ { + message := m.messages[i] + output.WriteString(message) + output.WriteString("\n") + } // Write the chat history to the file. - if writeErr := os.WriteFile(filepath.Join(*figs.String(kOutputDir), filename), []byte(logContent), 0644); writeErr != nil { + if writeErr := os.WriteFile(filepath.Join(*figs.String(kOutputDir), filename), output.Bytes(), 0644); writeErr != nil { fmt.Printf("\nāŒ Could not save chat log: %v\n", writeErr) } else { fmt.Printf("\nšŸ“ Chat log saved to %s\n", filename) @@ -84,6 +89,8 @@ type model struct { summary string isGenerating bool err error + ctx context.Context + chatHistory []string } // initialModel creates the starting state of our application. @@ -101,14 +108,22 @@ func initialModel(llm gollm.LLM, summary string) model { // The viewport is the scrolling area for the chat history. vp := viewport.New(0, 0) // Width and height are set dynamically + if len(summary) == 0 { + panic("no summary") + } + + msg := fmt.Sprintf("%s %d bytes!", "Welcome to Summarize AI Chat! We've analyzed your project workspace and are ready to chat with you about ", len(summary)) + return model{ llm: llm, textarea: ta, viewport: vp, summary: summary, - messages: []string{"Hi! I'm your local AI assistant called Summarize. How can I help you?"}, + messages: []string{msg}, + chatHistory: []string{}, isGenerating: false, err: nil, + ctx: context.Background(), } } @@ -117,6 +132,7 @@ func initialModel(llm gollm.LLM, summary string) model { func (m model) generateResponseCmd() tea.Cmd { return func() tea.Msg { userInput := m.textarea.Value() + m.chatHistory = append(m.chatHistory, userInput) var wc strings.Builder breaker := "---ARM-GO-SUMMARIZE-BREAK-POINT---" @@ -166,13 +182,19 @@ func (m model) generateResponseCmd() tea.Cmd { wc.WriteString("\n") } + var systemPrompt strings.Builder + systemPrompt.WriteString("This is a summary of a project that you are to respond to user prompts with the contents of this project.\n\n") + systemPrompt.WriteString(m.summary) + systemPrompt.WriteString("\n") + var ( response = "" err = errors.New("failed to ask agent for a response") prompt = gollm.NewPrompt(userInput, - gollm.WithContext(wc.String()), - gollm.WithDirectives("Only reply in raw ASCII.", - "Be concise and offer complete solutions", + gollm.WithContext(strings.Join(m.chatHistory, "\n")), + gollm.WithSystemPrompt(systemPrompt.String(), gollm.CacheTypeEphemeral), + gollm.WithMaxLength(7777), + gollm.WithDirectives("Be concise and offer complete solutions", "Act as Commander Data from the USS Starship Enterprise acting as an AI Agent assisting the user", "Refer to the user as Commander", "Speak as if you were on a Military Base as a member of the USS Starship Enterprise", @@ -180,11 +202,16 @@ func (m model) generateResponseCmd() tea.Cmd { "When replying to followup requests, build on your previous answer", "When a mistake is identified by the user, use the full previous response to modify and return", "Do not be afraid to offend and always give an honest answer in as few words as possible", - "Do not format the output in markdown, use just plain text to STDOUT through a redirect or pipe, thus no formatting at all.", + ), + gollm.WithOutput( + fmt.Sprintf("%s %d wide %d tall.", "Do not apply any formatting to the output"+ + " text except for line breaks and spaces. Commands and codes should be indented by 4 spaces "+ + "on the left and right side of the line and the text will render inside of a Golang BubbleTea"+ + "TUI window that is ", m.viewport.Width-5, m.viewport.Height-5), ), ) ) - response, err = m.llm.Generate(context.Background(), prompt) + response, err = m.llm.Generate(m.ctx, prompt) if err != nil { return errorMsg{err} // On error, return an error message. } diff --git a/configure.go b/configure.go index 3f839a0..7d7d87a 100644 --- a/configure.go +++ b/configure.go @@ -43,12 +43,16 @@ func configure() { figs = figs.NewInt(kAiMaxTokens, env.Int(eAiMaxTokens, dAiMaxTokens), "AI Max Tokens to use for query") figs = figs.NewInt(kAiSeed, env.Int(eAiSeed, dAiSeed), "AI Seed to use for query") figs = figs.NewString(kAiApiKey, env.String(eAiApiKey, ""), "AI API Key to use for query (leave empty for ollama)") + figs = figs.NewInt(kMemory, env.Int(eAiMemory, dMemory), "AI Memory to use for query") + figs = figs.NewBool(kAiCachingEnabled, env.Bool(eAiAlwaysEnableCache, dCachingEnabled), "Enable LLM caching") + figs = figs.NewUnitDuration(kAiTimeout, env.UnitDuration(eAiGlobalTimeout, dTimeoutUnit, dTimeout), dTimeoutUnit, "AI Timeout on each request allowed") // validators run internal figtree Assure funcs as arguments to validate against figs = figs.WithValidator(kSourceDir, figtree.AssureStringNotEmpty) figs = figs.WithValidator(kOutputDir, figtree.AssureStringNotEmpty) figs = figs.WithValidator(kFilename, figtree.AssureStringNotEmpty) - figs = figs.WithValidator(kMaxFiles, figtree.AssureIntInRange(1, 17_369)) + figs = figs.WithValidator(kMaxFiles, figtree.AssureIntInRange(1, 369)) + figs = figs.WithValidator(kMemory, figtree.AssureIntInRange(1, 17_369_369)) figs = figs.WithValidator(kMaxOutputSize, figtree.AssureInt64InRange(369, 369_369_369_369)) figs = figs.WithValidator(kAiSeed, figtree.AssureIntInRange(-1, 369_369_369_369)) figs = figs.WithValidator(kAiMaxTokens, figtree.AssureIntInRange(-1, 369_369_369_369)) diff --git a/const.go b/const.go index 93f7f2c..5d20981 100644 --- a/const.go +++ b/const.go @@ -1,5 +1,7 @@ package main +import "time" + const ( projectName string = "github.com/andreimerlescu/summarize" tFormat string = "2006.01.02.15.04.05.UTC" @@ -28,25 +30,35 @@ const ( // eAlwaysCompress ENV string-as-bool (as "TRUE" or "true" for true) always sets -gz true in CLI argument parsing eAlwaysCompress string = "SUMMARIZE_ALWAYS_COMPRESS" - eDisableAi string = "SUMMARIZE_DISABLE_AI" - eAiProvider string = "SUMMARIZE_AI_PROVIDER" - eAiModel string = "SUMMARIZE_AI_MODEL" - eAiApiKey string = "SUMMARIZE_AI_API_KEY" - eAiMaxTokens string = "SUMMARIZE_AI_MAX_TOKENS" - eAiSeed string = "SUMMARIZE_AI_SEED" + eDisableAi string = "SUMMARIZE_DISABLE_AI" + eAiProvider string = "SUMMARIZE_AI_PROVIDER" + eAiModel string = "SUMMARIZE_AI_MODEL" + eAiApiKey string = "SUMMARIZE_AI_API_KEY" + eAiMaxTokens string = "SUMMARIZE_AI_MAX_TOKENS" + eAiSeed string = "SUMMARIZE_AI_SEED" + eAiMemory string = "SUMMARIZE_AI_MEMORY" + eAiAlwaysEnableCache string = "SUMMARIZE_AI_ENABLE_CACHE" + eAiGlobalTimeout string = "SUMMARIZE_AI_GLOBAL_TIMEOUT" dAiSeed int = -1 dAiMaxTokens int = 3000 dAiProvider string = "ollama" - dAiModel string = "gpt-oss:20b" + dAiModel string = "qwen3:8b" // dAiModel string = "mistral-small3.2:24b" - - kAiEnabled string = "ai" - kAiProvider string = "provider" - kAiModel string = "model" - kAiApiKey string = "api-key" - kAiMaxTokens string = "max-tokens" - kAiSeed string = "seed" + dCachingEnabled bool = true + dMemory int = 36963 + dTimeout time.Duration = 77 + dTimeoutUnit time.Duration = time.Second + + kAiEnabled string = "ai" + kAiProvider string = "provider" + kAiModel string = "model" + kAiApiKey string = "api-key" + kAiMaxTokens string = "max-tokens" + kAiSeed string = "seed" + kMemory string = "memory" + kAiCachingEnabled string = "caching" + kAiTimeout string = "timeout" kShowExpanded string = "expand" diff --git a/main.go b/main.go index 3d446d8..8538c2f 100644 --- a/main.go +++ b/main.go @@ -253,7 +253,7 @@ func main() { if *figs.Bool(kChat) { StartChat(&buf) - path := latestSummaryFile() + path := latestChatLog() contents, err := os.ReadFile(path) if err != nil { old := buf.String() diff --git a/reflect.go b/reflect.go index bcaab56..dc381ec 100644 --- a/reflect.go +++ b/reflect.go @@ -8,7 +8,7 @@ import ( "time" ) -func latestSummaryFile() string { +func latestChatLog() string { entries, err := os.ReadDir(kOutputDir) if err != nil { if *figs.Bool(kDebug) { @@ -28,7 +28,7 @@ func latestSummaryFile() string { filename := entry.Name() // Check if file matches pattern: starts with "summary." and ends with ".md" - if strings.HasPrefix(filename, "summary.") && strings.HasSuffix(filename, ".md") { + if strings.HasPrefix(filename, "chatlog.") && strings.HasSuffix(filename, ".md") { fullPath := filepath.Join(kOutputDir, filename) fileInfo, err := entry.Info() From 8237ebd114a07973378870a8af2d3cbcc8c044d2 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sat, 16 Aug 2025 11:08:25 -0400 Subject: [PATCH 09/11] Updated version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index cb0d662..795460f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.1.0-beta.0 +v1.1.0 From 9567051d4b70ffe32717370375f9a71076d7c186 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sat, 16 Aug 2025 11:24:35 -0400 Subject: [PATCH 10/11] Addressed Copilot Feedback --- ai.go | 7 ++++--- chat.go | 24 +++++++++++++++++++++++- main.go | 14 ++++++++++---- reflect.go | 3 --- simplify.go | 2 +- 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/ai.go b/ai.go index 26683d3..055ef9e 100644 --- a/ai.go +++ b/ai.go @@ -1,7 +1,7 @@ package main import ( - "log" + "fmt" "os" "time" @@ -24,7 +24,7 @@ func NewAI() gollm.LLM { opts = append(opts, gollm.SetEnableCaching(*figs.Bool(kAiCachingEnabled))) timeout := *figs.UnitDuration(kAiTimeout) if timeout < time.Second { - panic("incorrect timeout value") + timeout = dTimeout * dTimeoutUnit } opts = append(opts, gollm.SetTimeout(*figs.UnitDuration(kAiTimeout))) switch provider { @@ -38,7 +38,8 @@ func NewAI() gollm.LLM { } llm, err := gollm.NewLLM(opts...) if err != nil { - log.Fatal(err) + fmt.Printf("āŒ Failed to initialize AI: %v\n", err) + return nil } return llm } diff --git a/chat.go b/chat.go index aef1a1d..f21c089 100644 --- a/chat.go +++ b/chat.go @@ -96,6 +96,17 @@ type model struct { // initialModel creates the starting state of our application. // CORRECTED: The llm parameter is now the interface type. func initialModel(llm gollm.LLM, summary string) model { + if llm == nil { + errMsg := "LLM is nil. Please try again later." + return model{ + llm: nil, + messages: []string{errorStyle.Render(errMsg)}, + chatHistory: []string{}, + isGenerating: false, + err: errors.New("empty summary"), + ctx: context.Background(), + } + } // Configure the text area for user input. ta := textarea.New() ta.Placeholder = "Send a message... (press Enter to send, Esc to quit)" @@ -109,7 +120,18 @@ func initialModel(llm gollm.LLM, summary string) model { vp := viewport.New(0, 0) // Width and height are set dynamically if len(summary) == 0 { - panic("no summary") + errMsg := "No project summary available. Please provide a valid summary to start the chat." + return model{ + llm: llm, + textarea: ta, + viewport: vp, + summary: summary, + messages: []string{errorStyle.Render(errMsg)}, + chatHistory: []string{}, + isGenerating: false, + err: errors.New("empty summary"), + ctx: context.Background(), + } } msg := fmt.Sprintf("%s %d bytes!", "Welcome to Summarize AI Chat! We've analyzed your project workspace and are ready to chat with you about ", len(summary)) diff --git a/main.go b/main.go index c5c391b..a1fb34c 100644 --- a/main.go +++ b/main.go @@ -22,21 +22,28 @@ func main() { configure() capture("figs loading environment", figs.Load()) + isDebug := *figs.Bool(kDebug) + inc := *figs.List(kIncludeExt) if len(inc) == 1 && inc[0] == "useExpanded" { figs.StoreList(kIncludeExt, extendedDefaultInclude) } - fmt.Println(strings.Join(inc, ", ")) + exc := *figs.List(kExcludeExt) if len(exc) == 1 && exc[0] == "useExpanded" { figs.StoreList(kExcludeExt, extendedDefaultExclude) } - fmt.Println(strings.Join(exc, ", ")) + ski := *figs.List(kSkipContains) if len(ski) == 1 && ski[0] == "useExpanded" { figs.StoreList(kSkipContains, extendedDefaultAvoid) } - fmt.Println(strings.Join(ski, ", ")) + + if isDebug { + fmt.Println("INCLUDE: ", strings.Join(inc, ", ")) + fmt.Println("EXCLUDE: ", strings.Join(exc, ", ")) + fmt.Println("SKIP: ", strings.Join(ski, ", ")) + } if *figs.Bool(kShowExpanded) { fmt.Println("Expanded:") @@ -45,7 +52,6 @@ func main() { fmt.Printf("-%s=%s\n", kSkipContains, strings.Join(*figs.List(kSkipContains), ",")) os.Exit(0) } - isDebug := *figs.Bool(kDebug) if *figs.Bool(kVersion) { fmt.Println(Version()) diff --git a/reflect.go b/reflect.go index dc381ec..ebae251 100644 --- a/reflect.go +++ b/reflect.go @@ -21,13 +21,11 @@ func latestChatLog() string { var latestModTime time.Time for _, entry := range entries { - // Skip directories if entry.IsDir() { continue } filename := entry.Name() - // Check if file matches pattern: starts with "summary." and ends with ".md" if strings.HasPrefix(filename, "chatlog.") && strings.HasSuffix(filename, ".md") { fullPath := filepath.Join(kOutputDir, filename) @@ -40,7 +38,6 @@ func latestChatLog() string { } modTime := fileInfo.ModTime() - // If this is the first matching file or it's newer than the current latest if latestFile == "" || modTime.After(latestModTime) { latestFile = fullPath latestModTime = modTime diff --git a/simplify.go b/simplify.go index 995f93b..081a035 100644 --- a/simplify.go +++ b/simplify.go @@ -3,7 +3,7 @@ package main // simplify takes a list of strings and reduces duplicates from the slice func simplify(t []string) []string { seen := make(map[string]bool) - results := make([]string, 0, len(t)) + results := make([]string, 0) for _, v := range t { if !seen[v] { seen[v] = true From b44e1fc3ebdbde68181fa300f90017ea61888458 Mon Sep 17 00:00:00 2001 From: Andrei Merlescu Date: Sat, 16 Aug 2025 19:45:45 -0400 Subject: [PATCH 11/11] Copilot updates --- ai.go | 2 +- chat.go | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/ai.go b/ai.go index 055ef9e..a78159f 100644 --- a/ai.go +++ b/ai.go @@ -14,7 +14,7 @@ func NewAI() gollm.LLM { var opts []gollm.ConfigOption opts = append(opts, gollm.SetProvider(provider)) opts = append(opts, gollm.SetModel(model)) - if seed > 1 { + if seed != -1 { opts = append(opts, gollm.SetSeed(seed)) } if maxTokens > 0 { diff --git a/chat.go b/chat.go index f21c089..ed5d968 100644 --- a/chat.go +++ b/chat.go @@ -29,9 +29,9 @@ var ( // A slight border for the chat viewport viewportStyle = lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("8")). // Gray - Padding(1) + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). // Gray + Padding(1) ) func StartChat(buf *bytes.Buffer) { @@ -169,8 +169,7 @@ func (m model) generateResponseCmd() tea.Cmd { wc.WriteString("The summarized project is:\n") parts := strings.Split(m.summary, breaker) if len(parts) == 2 { - oldPrefix := strings.Clone(parts[0]) - oldSummary := strings.Clone(parts[1]) + oldPrefix, oldSummary := parts[0], parts[1] newSummary := oldPrefix + wc.String() + oldSummary m.summary = newSummary wc.Reset() @@ -196,7 +195,7 @@ func (m model) generateResponseCmd() tea.Cmd { "the code. However, do not get distracted. Always follow the lead of the DevOps engineer. Do not be afraid to" + "offend. Your brutal honesty is welcome here and iron sharpens iron. Here is the summary now:\n" + breaker + "\n") - oldSummary := strings.Clone(m.summary) + oldSummary := m.summary newSummary := oldSummary + wc.String() m.summary = newSummary wc.Reset()