Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 86 additions & 11 deletions cmd/engram/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,8 @@ var (
storeDeleteProject = func(s *store.Store, name string, hard bool) (*store.DeleteProjectResult, error) {
return s.DeleteProject(name, hard)
}
storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) {
storePruneProject = func(s *store.Store, name string) (*store.PruneResult, error) { return s.PruneProject(name) }
storeTimeline = func(s *store.Store, observationID int64, before, after int) (*store.TimelineResult, error) {
return s.Timeline(observationID, before, after)
}
storeFormatContext = func(s *store.Store, project, scope string) (string, error) { return s.FormatContext(project, scope) }
Expand Down Expand Up @@ -1861,7 +1862,7 @@ func cmdProjects(cfg store.Config) {
fmt.Fprintf(os.Stderr, "unknown projects subcommand: %s\n", subCmd)
fmt.Fprintln(os.Stderr, "usage: engram projects list")
fmt.Fprintln(os.Stderr, " engram projects consolidate [--all] [--dry-run]")
fmt.Fprintln(os.Stderr, " engram projects prune [--dry-run]")
fmt.Fprintln(os.Stderr, " engram projects prune [--dry-run] [--paths-only]")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
exitFunc(1)
}
}
Expand Down Expand Up @@ -1962,6 +1963,47 @@ func findNormalizationEquivalentProjects(name string, existing []string) []proje
return matches
}

// mergedRecordCount reports how many records a merge actually moved. The store
// validates every source against the canonical name and fail-closes on the ones
// it cannot prove normalization-equivalent, so a merge can succeed while moving
// nothing at all. Callers must report that outcome honestly instead of
// announcing a completed merge.
func mergedRecordCount(result *store.MergeResult) int64 {
if result == nil {
return 0
}
return result.ObservationsUpdated + result.SessionsUpdated + result.PromptsUpdated
}

// reportUnmergedSources names the selected sources the store left untouched, so
// a partially applied merge never reads as a complete one.
func reportUnmergedSources(sources []string, result *store.MergeResult) {
merged := make(map[string]bool, len(result.SourcesMerged))
for _, name := range result.SourcesMerged {
merged[name] = true
}

// SourcesMerged holds the trimmed spelling the store actually rewrote, while
// sources holds the raw spellings the operator selected. Only a source that
// is literally the canonical name was a no-op by request.
var skipped []string
seen := make(map[string]bool, len(sources))
for _, source := range sources {
if strings.TrimSpace(source) == "" || source == result.Canonical {
continue
}
if merged[strings.TrimSpace(source)] || seen[source] {
continue
}
seen[source] = true
skipped = append(skipped, source)
}
if len(skipped) == 0 {
return
}
fmt.Printf(" Not merged (no records moved): %s\n", strings.Join(skipped, ", "))
}

func cmdProjectsConsolidate(cfg store.Config) {
doAll := false
dryRun := false
Expand Down Expand Up @@ -2073,10 +2115,17 @@ func cmdProjectsConsolidate(cfg store.Config) {
fatal(err)
}

fmt.Printf("Done! Merged into %q:\n", result.Canonical)
if mergedRecordCount(result) == 0 {
fmt.Printf("Nothing merged into %q: the store moved no records for the %d selected project(s).\n",
result.Canonical, len(sources))
return
}

fmt.Printf("Done! Merged %d project(s) into %q:\n", len(result.SourcesMerged), result.Canonical)
fmt.Printf(" Observations: %d\n", result.ObservationsUpdated)
fmt.Printf(" Sessions: %d\n", result.SessionsUpdated)
fmt.Printf(" Prompts: %d\n", result.PromptsUpdated)
reportUnmergedSources(sources, result)
return
}

Expand Down Expand Up @@ -2194,8 +2243,14 @@ func cmdProjectsConsolidate(cfg store.Config) {
fmt.Println()
continue
}
fmt.Printf(" Merged: %d obs, %d sessions, %d prompts\n",
result.ObservationsUpdated, result.SessionsUpdated, result.PromptsUpdated)
if mergedRecordCount(result) == 0 {
fmt.Printf(" Nothing merged into %q: the store moved no records for the %d selected project(s).\n",
mergeCanonical, len(sources))
} else {
fmt.Printf(" Merged: %d obs, %d sessions, %d prompts\n",
result.ObservationsUpdated, result.SessionsUpdated, result.PromptsUpdated)
reportUnmergedSources(sources, result)
}

if renameTarget != "" && renameTarget != mergeCanonical {
migrateResult, err := s.MigrateProject(mergeCanonical, renameTarget)
Expand All @@ -2214,9 +2269,13 @@ func cmdProjectsConsolidate(cfg store.Config) {

func cmdProjectsPrune(cfg store.Config) {
dryRun := false
pathsOnly := false
for i := 3; i < len(os.Args); i++ {
if os.Args[i] == "--dry-run" {
switch os.Args[i] {
case "--dry-run":
dryRun = true
case "--paths-only":
pathsOnly = true
}
}

Expand All @@ -2231,15 +2290,21 @@ func cmdProjectsPrune(cfg store.Config) {
fatal(err)
}

// Find projects with 0 observations
// Find projects with 0 observations.
var candidates []store.ProjectStats
for _, ps := range allStats {
if ps.ObservationCount == 0 {
candidates = append(candidates, ps)
if ps.ObservationCount != 0 || (pathsOnly && !isPathLikeProjectName(ps.Name)) {
continue
}
candidates = append(candidates, ps)
}
sort.Slice(candidates, func(i, j int) bool { return candidates[i].Name < candidates[j].Name })

if len(candidates) == 0 {
if pathsOnly {
fmt.Println("No path-named projects to prune.")
return
}
fmt.Println("No empty projects to prune.")
return
}
Expand Down Expand Up @@ -2286,17 +2351,23 @@ func cmdProjectsPrune(cfg store.Config) {

totalSessions := int64(0)
totalPrompts := int64(0)
successful := 0
for _, ps := range selected {
result, err := s.PruneProject(ps.Name)
result, err := storePruneProject(s, ps.Name)
if err != nil {
fmt.Fprintf(os.Stderr, "Error pruning %q: %v\n", ps.Name, err)
continue
}
successful++
totalSessions += result.SessionsDeleted
totalPrompts += result.PromptsDeleted
}

fmt.Printf("\nPruned %d project(s): %d sessions, %d prompts removed.\n", len(selected), totalSessions, totalPrompts)
fmt.Printf("\nPruned %d project(s): %d sessions, %d prompts removed.\n", successful, totalSessions, totalPrompts)
}

func isPathLikeProjectName(name string) bool {
return strings.ContainsAny(name, `/\`)
}

// cmdSetup classifies os.Args[2:] with a two-pass, order-independent
Expand Down Expand Up @@ -2645,6 +2716,10 @@ Commands:
Merge similar project names into one canonical name
--all Scan ALL projects for similar name groups
--dry-run Preview what would be merged (no changes)
projects prune [--dry-run] [--paths-only]
Remove projects with no observations
--dry-run Preview projects without removing data
--paths-only Limit pruning to project names containing / or \
setup [agent] Install/setup agent integration (opencode, pi, claude-code,
gemini-cli, codex, antigravity-cli, windsurf, qwen, kiro,
cursor, vscode-copilot, kilocode)
Expand Down
Loading