Skip to content

Commit f0b7a2b

Browse files
committed
test message
1 parent 8520082 commit f0b7a2b

6 files changed

Lines changed: 270 additions & 35 deletions

File tree

cmd/iterate/main_mode.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,14 @@ func loadEvolutionSession(f mainFlags, logger *slog.Logger) []iteragent.Message
137137

138138
if f.compactFlag && len(sessionMessages) > 0 {
139139
ctxCfg := iteragent.DefaultContextConfig()
140-
sessionMessages = iteragent.CompactMessagesTiered(sessionMessages, ctxCfg)
140+
// Use LLM-aware compaction strategy if a provider is available,
141+
// otherwise fall back to the tiered (non-LLM) compaction.
142+
if p, err := iteragent.NewProvider("", ""); err == nil && p != nil {
143+
strategy := &iteragent.LLMCompactionStrategy{Provider: p, KeepRecent: ctxCfg.KeepRecent}
144+
sessionMessages = strategy.Compact(sessionMessages, ctxCfg.MaxTokens)
145+
} else {
146+
sessionMessages = iteragent.CompactMessagesTiered(sessionMessages, ctxCfg)
147+
}
141148
logger.Info("compacted session messages", "remaining", len(sessionMessages))
142149
}
143150
return sessionMessages

cmd/iterate/repl.go

Lines changed: 43 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -536,10 +536,48 @@ func buildCommandContext(repoPath, line string, parts []string, p iteragent.Prov
536536
}
537537
}
538538

539+
// atFileCache is a session-scoped cache of the repo's filename index.
540+
var atFileCache struct {
541+
repoPath string
542+
index map[string]string // lowercase base → rel path
543+
}
544+
545+
// buildAtFileIndex walks repoPath once and caches the result.
546+
func buildAtFileIndex(repoPath string) map[string]string {
547+
if atFileCache.repoPath == repoPath && atFileCache.index != nil {
548+
return atFileCache.index
549+
}
550+
const maxScan = 800
551+
idx := make(map[string]string)
552+
count := 0
553+
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
554+
if err != nil {
555+
return nil
556+
}
557+
if info.IsDir() {
558+
n := info.Name()
559+
if n == ".git" || n == "node_modules" || n == "vendor" || n == "dist" || n == "__pycache__" || n == ".next" {
560+
return filepath.SkipDir
561+
}
562+
return nil
563+
}
564+
if count >= maxScan {
565+
return filepath.SkipDir
566+
}
567+
rel, _ := filepath.Rel(repoPath, path)
568+
base := strings.ToLower(filepath.Base(rel))
569+
idx[base] = rel
570+
count++
571+
return nil
572+
})
573+
atFileCache.repoPath = repoPath
574+
atFileCache.index = idx
575+
return idx
576+
}
577+
539578
// suggestAtFiles scans the prompt for words that match repo filenames without
540579
// an @-prefix already present, and prints a one-line dim hint.
541580
func suggestAtFiles(prompt, repoPath string) {
542-
const maxScan = 500
543581
const minWordLen = 4
544582

545583
// Skip if the prompt already uses @-references.
@@ -551,7 +589,6 @@ func suggestAtFiles(prompt, repoPath string) {
551589
var candidates []string
552590
seen := make(map[string]bool)
553591
for _, w := range words {
554-
// Strip trailing punctuation.
555592
w = strings.TrimRight(w, ".,;:!?\"')")
556593
if len(w) < minWordLen {
557594
continue
@@ -566,35 +603,15 @@ func suggestAtFiles(prompt, repoPath string) {
566603
return
567604
}
568605

569-
// Build a quick filename index (base names only, limited scan).
570-
fileIndex := make(map[string]string) // base → rel path
571-
count := 0
572-
_ = filepath.Walk(repoPath, func(path string, info os.FileInfo, err error) error {
573-
if err != nil || info.IsDir() {
574-
if info != nil && info.IsDir() {
575-
n := info.Name()
576-
if n == ".git" || n == "node_modules" || n == "vendor" || n == "dist" {
577-
return filepath.SkipDir
578-
}
579-
}
580-
return nil
581-
}
582-
if count >= maxScan {
583-
return filepath.SkipDir
584-
}
585-
rel, _ := filepath.Rel(repoPath, path)
586-
base := filepath.Base(rel)
587-
// index both the base and the rel path
588-
fileIndex[strings.ToLower(base)] = rel
589-
count++
590-
return nil
591-
})
606+
fileIndex := buildAtFileIndex(repoPath)
592607

593608
var hints []string
609+
hintSeen := make(map[string]bool)
594610
for _, word := range candidates {
595611
lower := strings.ToLower(word)
596612
for base, rel := range fileIndex {
597-
if strings.Contains(base, lower) {
613+
if strings.Contains(base, lower) && !hintSeen[rel] {
614+
hintSeen[rel] = true
598615
hints = append(hints, "@"+rel)
599616
break
600617
}

cmd/iterate/repl_streaming.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,9 +365,12 @@ func updateSessionTokens(a *iteragent.Agent, fullContent string) (inputDelta, ou
365365
}
366366
}
367367
// Fallback: approximate from streamed content length.
368+
// This happens when the provider doesn't return usage metadata.
368369
approxTokens := len(fullContent) / 4
369370
sess.Tokens += approxTokens
370371
sess.OutputTokens += approxTokens
372+
slog.Debug("token usage not reported by provider; cost estimate will be approximate",
373+
"approx_output_tokens", approxTokens)
371374
return 0, approxTokens, 0, 0
372375
}
373376

internal/commands/utility.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,22 @@ func registerUtilityActionCommands(r *Registry) {
122122
Category: "utility",
123123
Handler: cmdUndoFiles,
124124
})
125+
126+
r.Register(Command{
127+
Name: "/scope",
128+
Aliases: []string{},
129+
Description: "focus agent on specific files/dirs: /scope path1 path2 ...",
130+
Category: "utility",
131+
Handler: cmdScope,
132+
})
133+
134+
r.Register(Command{
135+
Name: "/perf",
136+
Aliases: []string{},
137+
Description: "show token usage breakdown per conversation turn",
138+
Category: "utility",
139+
Handler: cmdPerf,
140+
})
125141
}
126142

127143
func cmdContext(ctx Context) Result {
@@ -650,3 +666,138 @@ func cmdMap(ctx Context) Result {
650666

651667
return Result{Handled: true}
652668
}
669+
670+
// cmdScope focuses the agent on a specific set of files or directories by
671+
// injecting a scoped context message and optionally prepending a system note.
672+
// Usage: /scope path1 path2 ...
673+
// With no args, shows the current scope or clears it.
674+
func cmdScope(ctx Context) Result {
675+
if ctx.Agent == nil {
676+
PrintError("no agent available")
677+
return Result{Handled: true}
678+
}
679+
680+
if !ctx.HasArg(1) {
681+
// Show current scope: look for a scope marker in recent messages.
682+
for i := len(ctx.Agent.Messages) - 1; i >= 0; i-- {
683+
if strings.HasPrefix(ctx.Agent.Messages[i].Content, "[Scope]") {
684+
snippet := ctx.Agent.Messages[i].Content
685+
if len(snippet) > 200 {
686+
snippet = snippet[:200] + "…"
687+
}
688+
fmt.Printf("%s%s%s\n\n", ColorDim, snippet, ColorReset)
689+
return Result{Handled: true}
690+
}
691+
}
692+
fmt.Printf("%sNo scope set. Use /scope path1 path2 ... to focus the agent.%s\n\n", ColorDim, ColorReset)
693+
return Result{Handled: true}
694+
}
695+
696+
paths := ctx.Parts[1:]
697+
var verified []string
698+
for _, p := range paths {
699+
abs := p
700+
if !strings.HasPrefix(p, "/") {
701+
abs = filepath.Join(ctx.RepoPath, p)
702+
}
703+
if _, err := os.Stat(abs); err == nil {
704+
rel, _ := filepath.Rel(ctx.RepoPath, abs)
705+
verified = append(verified, rel)
706+
} else {
707+
fmt.Printf("%s warning: %s not found%s\n", ColorDim, p, ColorReset)
708+
}
709+
}
710+
711+
if len(verified) == 0 {
712+
PrintError("no valid paths found")
713+
return Result{Handled: true}
714+
}
715+
716+
scopeMsg := fmt.Sprintf("[Scope] Focus exclusively on the following paths for all changes and analysis:\n%s\n\nOnly read, edit, or reference files within these paths unless explicitly asked otherwise.",
717+
" - "+strings.Join(verified, "\n - "))
718+
719+
ctx.Agent.Messages = append(ctx.Agent.Messages, iteragent.NewUserMessage(scopeMsg))
720+
PrintSuccess("scope set to: %s", strings.Join(verified, ", "))
721+
return Result{Handled: true}
722+
}
723+
724+
// cmdPerf shows a per-turn breakdown of estimated token usage in the conversation.
725+
// Since most providers return only final usage totals, this estimates per-message
726+
// cost from content length with a clear approximation disclaimer.
727+
func cmdPerf(ctx Context) Result {
728+
if ctx.Agent == nil || len(ctx.Agent.Messages) == 0 {
729+
fmt.Println("No conversation to profile.")
730+
return Result{Handled: true}
731+
}
732+
733+
const charPerToken = 4
734+
type turnStat struct {
735+
idx int
736+
role string
737+
chars int
738+
tokens int
739+
}
740+
741+
var turns []turnStat
742+
totalChars := 0
743+
for i, m := range ctx.Agent.Messages {
744+
if m.Role == "system" {
745+
continue
746+
}
747+
chars := len(m.Content)
748+
totalChars += chars
749+
turns = append(turns, turnStat{
750+
idx: i,
751+
role: m.Role,
752+
chars: chars,
753+
tokens: chars / charPerToken,
754+
})
755+
}
756+
757+
if len(turns) == 0 {
758+
fmt.Println("No non-system messages.")
759+
return Result{Handled: true}
760+
}
761+
762+
maxTokens := 0
763+
for _, t := range turns {
764+
if t.tokens > maxTokens {
765+
maxTokens = t.tokens
766+
}
767+
}
768+
769+
fmt.Printf("%s── Token Profile (approx ~1 tok/4 chars) ─────────────%s\n", ColorDim, ColorReset)
770+
for _, t := range turns {
771+
roleColor := ColorDim
772+
roleLabel := "assistant"
773+
if t.role == "user" {
774+
roleColor = ColorCyan
775+
roleLabel = "user "
776+
}
777+
778+
barWidth := 20
779+
barFill := 0
780+
if maxTokens > 0 {
781+
barFill = t.tokens * barWidth / maxTokens
782+
}
783+
bar := strings.Repeat("▪", barFill) + strings.Repeat("·", barWidth-barFill)
784+
785+
snippet := strings.ReplaceAll(strings.TrimSpace(ctx.Agent.Messages[t.idx].Content), "\n", " ")
786+
if len(snippet) > 40 {
787+
snippet = snippet[:40] + "…"
788+
}
789+
790+
fmt.Printf(" %s%-9s%s [%s%s%s] %s~%d tok%s %s%s%s\n",
791+
roleColor, roleLabel, ColorReset,
792+
ColorBold, bar, ColorReset,
793+
ColorDim, t.tokens, ColorReset,
794+
ColorDim, snippet, ColorReset)
795+
}
796+
797+
totalApprox := totalChars / charPerToken
798+
fmt.Printf("%s────────────────────────────────────────────────────%s\n", ColorDim, ColorReset)
799+
fmt.Printf(" Total: ~%s (%d messages)\n\n",
800+
formatTokenCount(totalApprox), len(turns))
801+
802+
return Result{Handled: true}
803+
}

internal/evolution/engine.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ func (e *Engine) savePRState() error {
151151
}
152152

153153
// loadPRState restores PR info from file at engine creation.
154+
// It also validates that the PR still exists on GitHub; if not it clears
155+
// the stale state so the next cycle starts fresh.
154156
func (e *Engine) loadPRState() {
155157
path := filepath.Join(e.repoPath, prStateFile)
156158
data, err := os.ReadFile(path)
@@ -163,11 +165,37 @@ func (e *Engine) loadPRState() {
163165
os.Remove(path)
164166
return
165167
}
168+
169+
// Validate the PR is still open on GitHub before trusting the state.
170+
// This guards against the scenario where the PR was merged/closed/deleted
171+
// externally and pr_state.json was not cleared (e.g. a crash in phase 5).
172+
if state.PRNumber > 0 && e.repo != "" {
173+
out, ghErr := e.runGHPRState(state.PRNumber)
174+
if ghErr != nil || (out != "OPEN" && out != "") {
175+
e.logger.Warn("pr_state.json refers to a non-open PR, clearing it",
176+
"pr", state.PRNumber, "state", out, "err", ghErr)
177+
os.Remove(path)
178+
return
179+
}
180+
}
181+
166182
e.prNumber = state.PRNumber
167183
e.prURL = state.PRURL
168184
e.branchName = state.Branch
169185
}
170186

187+
// runGHPRState returns the GitHub PR state string ("OPEN", "MERGED", "CLOSED")
188+
// for the given PR number, or an error if the check fails.
189+
func (e *Engine) runGHPRState(prNumber int) (string, error) {
190+
cmd := fmt.Sprintf("gh pr view %d --repo %s --json state --jq .state 2>/dev/null || echo UNKNOWN",
191+
prNumber, e.repo)
192+
out, err := e.runTool(context.Background(), "bash", map[string]interface{}{"cmd": cmd})
193+
if err != nil {
194+
return "", err
195+
}
196+
return strings.TrimSpace(out), nil
197+
}
198+
171199
// clearPRState removes the PR state file.
172200
func (e *Engine) clearPRState() {
173201
path := filepath.Join(e.repoPath, prStateFile)

0 commit comments

Comments
 (0)