464 lines
11 KiB
Go
464 lines
11 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/hjbdev/patterm/internal/preset"
|
|
)
|
|
|
|
const (
|
|
summaryTickInterval = time.Second
|
|
summaryTimeout = 90 * time.Second
|
|
summaryMaxLineCells = 240
|
|
)
|
|
|
|
type summaryState struct {
|
|
Text string
|
|
State IdleState
|
|
UpdatedAt time.Time
|
|
Error string
|
|
}
|
|
|
|
type summaryManager struct {
|
|
sess *Session
|
|
projectDir string
|
|
presets preset.Set
|
|
settings func() autoSummarySettings
|
|
onUpdate func()
|
|
onResult func(string, summaryState)
|
|
|
|
mu sync.Mutex
|
|
tracked map[string]bool
|
|
entries map[string]*summaryEntry
|
|
}
|
|
|
|
type summaryEntry struct {
|
|
armed bool
|
|
dirty bool
|
|
running bool
|
|
lastInputAt time.Time
|
|
lastOutputAt time.Time
|
|
lastAttemptAt time.Time
|
|
lastSummarized int64
|
|
state summaryState
|
|
}
|
|
|
|
type summarizerResponse struct {
|
|
Summary string `json:"summary"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
func newSummaryManager(sess *Session, projectDir string, presets preset.Set, settingsFn func() autoSummarySettings, onUpdate func(), onResult func(string, summaryState)) *summaryManager {
|
|
return &summaryManager{
|
|
sess: sess,
|
|
projectDir: projectDir,
|
|
presets: presets,
|
|
settings: settingsFn,
|
|
onUpdate: onUpdate,
|
|
onResult: onResult,
|
|
tracked: make(map[string]bool),
|
|
entries: make(map[string]*summaryEntry),
|
|
}
|
|
}
|
|
|
|
func (m *summaryManager) run(ctx context.Context) {
|
|
ticker := time.NewTicker(summaryTickInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
m.maybeStart(ctx, time.Now())
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *summaryManager) ObserveHumanInput(childID string, b []byte) {
|
|
if m == nil || !m.isTracked(childID) {
|
|
return
|
|
}
|
|
cfg := m.settings()
|
|
if len(strings.TrimSpace(string(b))) < cfg.MinInputChars {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
e := m.entryLocked(childID)
|
|
e.armed = true
|
|
e.lastInputAt = time.Now()
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *summaryManager) ObserveOutput(childID string) {
|
|
if m == nil || !m.isTracked(childID) {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
e := m.entryLocked(childID)
|
|
if e.armed {
|
|
e.dirty = true
|
|
e.lastOutputAt = time.Now()
|
|
}
|
|
m.mu.Unlock()
|
|
}
|
|
|
|
func (m *summaryManager) RegisterChild(c *Child) {
|
|
if m == nil || c == nil {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if isTopLevelSummarizedAgent(c) {
|
|
m.tracked[c.ID] = true
|
|
} else {
|
|
delete(m.tracked, c.ID)
|
|
}
|
|
}
|
|
|
|
func (m *summaryManager) UnregisterChild(id string) {
|
|
if m == nil || id == "" {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.tracked, id)
|
|
}
|
|
|
|
func (m *summaryManager) isTracked(id string) bool {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.tracked[id]
|
|
}
|
|
|
|
func (m *summaryManager) Summary(childID string) summaryState {
|
|
if m == nil || childID == "" {
|
|
return summaryState{}
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if e := m.entries[childID]; e != nil {
|
|
return e.state
|
|
}
|
|
return summaryState{}
|
|
}
|
|
|
|
func (m *summaryManager) RunNow(ctx context.Context, childID string) {
|
|
if m == nil || childID == "" {
|
|
return
|
|
}
|
|
c := m.sess.FindChild(childID)
|
|
if !isTopLevelSummarizedAgent(c) {
|
|
return
|
|
}
|
|
m.mu.Lock()
|
|
e := m.entryLocked(c.ID)
|
|
if e.running {
|
|
m.mu.Unlock()
|
|
return
|
|
}
|
|
e.running = true
|
|
e.lastAttemptAt = time.Now()
|
|
m.mu.Unlock()
|
|
go m.runOne(ctx, c.ID, true)
|
|
}
|
|
|
|
func (m *summaryManager) Test(ctx context.Context) error {
|
|
cfg := m.settings()
|
|
return runSummarizerHealth(ctx, cfg, m.projectDir)
|
|
}
|
|
|
|
func (m *summaryManager) entryLocked(id string) *summaryEntry {
|
|
e := m.entries[id]
|
|
if e == nil {
|
|
e = &summaryEntry{}
|
|
m.entries[id] = e
|
|
}
|
|
return e
|
|
}
|
|
|
|
func (m *summaryManager) maybeStart(ctx context.Context, now time.Time) {
|
|
cfg := m.settings()
|
|
if !cfg.Enabled {
|
|
return
|
|
}
|
|
cadence, err := time.ParseDuration(cfg.Cadence)
|
|
if err != nil || cadence <= 0 {
|
|
cadence = 5 * time.Minute
|
|
}
|
|
quiet := time.Duration(cfg.QuietWindowMS) * time.Millisecond
|
|
var startID string
|
|
for _, c := range m.sess.Children() {
|
|
if !isTopLevelSummarizedAgent(c) {
|
|
continue
|
|
}
|
|
m.mu.Lock()
|
|
e := m.entryLocked(c.ID)
|
|
eligible := e.armed && e.dirty && !e.running &&
|
|
!e.lastOutputAt.IsZero() && now.Sub(e.lastOutputAt) >= quiet &&
|
|
(e.lastAttemptAt.IsZero() || now.Sub(e.lastAttemptAt) >= cadence) &&
|
|
c.ScreenVersion() != e.lastSummarized
|
|
if eligible {
|
|
e.running = true
|
|
e.lastAttemptAt = now
|
|
startID = c.ID
|
|
}
|
|
m.mu.Unlock()
|
|
if startID != "" {
|
|
go m.runOne(ctx, startID, false)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *summaryManager) runOne(ctx context.Context, childID string, manual bool) {
|
|
c := m.sess.FindChild(childID)
|
|
if c == nil {
|
|
m.finish(childID, summaryState{Error: "process disappeared"}, 0)
|
|
return
|
|
}
|
|
cfg := m.settings()
|
|
snapshot := buildSummarySnapshot(c, cfg.MaxHistoryChars, m.chromeHintsFor(c.PresetRef))
|
|
if strings.TrimSpace(snapshot) == "" {
|
|
m.finish(childID, summaryState{Error: "empty snapshot"}, c.ScreenVersion())
|
|
return
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, summaryTimeout)
|
|
defer cancel()
|
|
resp, err := runSummarizer(runCtx, cfg, m.projectDir, snapshot)
|
|
st := summaryState{UpdatedAt: time.Now()}
|
|
if err != nil {
|
|
st.Error = err.Error()
|
|
m.finish(childID, st, c.ScreenVersion())
|
|
return
|
|
}
|
|
st.Text = strings.TrimSpace(resp.Summary)
|
|
st.State = summaryIdleState(resp.State)
|
|
if st.Text == "" {
|
|
st.Error = "empty summary"
|
|
}
|
|
if manual && st.Text != "" && st.State == StateUnknown {
|
|
st.State = c.IdleState()
|
|
}
|
|
m.finish(childID, st, c.ScreenVersion())
|
|
}
|
|
|
|
func (m *summaryManager) finish(childID string, st summaryState, version int64) {
|
|
m.mu.Lock()
|
|
e := m.entryLocked(childID)
|
|
e.running = false
|
|
if st.Text != "" || st.Error != "" {
|
|
if st.Text == "" && e.state.Text != "" {
|
|
st.Text = e.state.Text
|
|
st.State = e.state.State
|
|
st.UpdatedAt = e.state.UpdatedAt
|
|
}
|
|
e.state = st
|
|
}
|
|
if st.Text != "" {
|
|
e.armed = false
|
|
e.dirty = false
|
|
e.lastSummarized = version
|
|
}
|
|
m.mu.Unlock()
|
|
if m.onUpdate != nil {
|
|
m.onUpdate()
|
|
}
|
|
if m.onResult != nil && (st.Text != "" || st.Error != "") {
|
|
m.onResult(childID, st)
|
|
}
|
|
}
|
|
|
|
func isTopLevelSummarizedAgent(c *Child) bool {
|
|
return c != nil && c.Kind == KindAgent && c.ParentID == "" && c.Status() == StatusRunning
|
|
}
|
|
|
|
func (m *summaryManager) chromeHintsFor(presetName string) []string {
|
|
if presetName == "" {
|
|
return nil
|
|
}
|
|
for _, p := range m.presets.Agents {
|
|
if p.Name == presetName {
|
|
return p.ChromeTrimHints
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildSummarySnapshot(c *Child, maxChars int, chromeHints []string) string {
|
|
if maxChars <= 0 {
|
|
maxChars = 12000
|
|
}
|
|
grid := ""
|
|
if em := c.Emulator(); em != nil {
|
|
if txt, err := em.PlainText(); err == nil {
|
|
grid = compactSummaryText(applyChromeTrim(txt, chromeHints))
|
|
}
|
|
}
|
|
tailBytes := max(maxChars*4, maxChars)
|
|
b := c.tailBytes(tailBytes)
|
|
history := compactSummaryText(applyChromeTrim(string(stripANSIBytes(nil, b)), chromeHints))
|
|
history = tailString(history, maxChars)
|
|
var out strings.Builder
|
|
if history != "" {
|
|
out.WriteString("Recent rendered history:\n")
|
|
out.WriteString(history)
|
|
out.WriteString("\n\n")
|
|
}
|
|
if grid != "" && !strings.Contains(history, grid) {
|
|
out.WriteString("Current visible grid:\n")
|
|
out.WriteString(grid)
|
|
}
|
|
return tailString(out.String(), maxChars)
|
|
}
|
|
|
|
func compactSummaryText(in string) string {
|
|
in = string(stripANSIBytes(nil, []byte(in)))
|
|
in = strings.ReplaceAll(in, "\r\n", "\n")
|
|
in = strings.ReplaceAll(in, "\r", "\n")
|
|
lines := strings.Split(in, "\n")
|
|
out := make([]string, 0, len(lines))
|
|
blank := false
|
|
for _, line := range lines {
|
|
line = strings.TrimRightFunc(line, unicode.IsSpace)
|
|
line = strings.Map(func(r rune) rune {
|
|
if r == '\t' || r == '\n' {
|
|
return r
|
|
}
|
|
if r < 0x20 || r == 0x7f {
|
|
return -1
|
|
}
|
|
return r
|
|
}, line)
|
|
line = truncateSummaryLine(line, summaryMaxLineCells)
|
|
if strings.TrimSpace(line) == "" {
|
|
if blank {
|
|
continue
|
|
}
|
|
blank = true
|
|
out = append(out, "")
|
|
continue
|
|
}
|
|
blank = false
|
|
out = append(out, line)
|
|
}
|
|
return strings.TrimSpace(strings.Join(out, "\n"))
|
|
}
|
|
|
|
func truncateSummaryLine(s string, max int) string {
|
|
if max <= 0 || visibleLen(s) <= max {
|
|
return s
|
|
}
|
|
return clipRunes(s, max-1) + "…"
|
|
}
|
|
|
|
func tailString(s string, max int) string {
|
|
rs := []rune(s)
|
|
if len(rs) <= max {
|
|
return s
|
|
}
|
|
return string(rs[len(rs)-max:])
|
|
}
|
|
|
|
func runSummarizer(ctx context.Context, cfg autoSummarySettings, projectDir, snapshot string) (summarizerResponse, error) {
|
|
prompt := summaryPrompt(snapshot)
|
|
out, err := runSummarizerCommand(ctx, cfg, projectDir, prompt)
|
|
if err != nil {
|
|
return summarizerResponse{}, err
|
|
}
|
|
resp, err := parseSummarizerResponse(out)
|
|
if err != nil {
|
|
return summarizerResponse{}, err
|
|
}
|
|
if summaryIdleState(resp.State) == StateUnknown {
|
|
return summarizerResponse{}, fmt.Errorf("invalid summary state %q", resp.State)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func runSummarizerHealth(ctx context.Context, cfg autoSummarySettings, projectDir string) error {
|
|
out, err := runSummarizerCommand(ctx, cfg, projectDir, "Reply with exactly: patterm okay")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(out) != "patterm okay" {
|
|
return fmt.Errorf("health check did not return patterm okay")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func runSummarizerCommand(ctx context.Context, cfg autoSummarySettings, projectDir, prompt string) (string, error) {
|
|
provider := cfg.Provider
|
|
model := cfg.modelFor(provider)
|
|
var cmd *exec.Cmd
|
|
switch provider {
|
|
case "opencode":
|
|
cmd = exec.CommandContext(ctx, "opencode", "run", "--model", model, "--dir", projectDir, prompt)
|
|
case "claude":
|
|
cmd = exec.CommandContext(ctx, "claude", "--print", "--model", model, prompt)
|
|
default:
|
|
cmd = exec.CommandContext(ctx, "codex", "exec", "--ephemeral", "--skip-git-repo-check", "--sandbox", "read-only", "--ask-for-approval", "never", "--model", model, "-")
|
|
cmd.Stdin = strings.NewReader(prompt)
|
|
}
|
|
cmd.Dir = projectDir
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
msg := strings.TrimSpace(stderr.String())
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return "", fmt.Errorf("%s summarizer: %s", provider, msg)
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
func summaryPrompt(snapshot string) string {
|
|
return "Summarize this terminal/agent snapshot for a compact UI catch-up aid.\n" +
|
|
"Return only JSON with keys summary and state. State must be one of IDLE, PERMISSION, THINKING, WORKING, ERROR.\n" +
|
|
"Keep summary under 180 characters, concrete, and avoid mentioning that you are summarizing.\n\n" +
|
|
snapshot
|
|
}
|
|
|
|
func parseSummarizerResponse(out string) (summarizerResponse, error) {
|
|
var resp summarizerResponse
|
|
if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &resp); err == nil {
|
|
return resp, nil
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "{") || !strings.HasSuffix(line, "}") {
|
|
continue
|
|
}
|
|
if err := json.Unmarshal([]byte(line), &resp); err == nil {
|
|
return resp, nil
|
|
}
|
|
}
|
|
return resp, fmt.Errorf("summary output was not JSON")
|
|
}
|
|
|
|
func summaryIdleState(s string) IdleState {
|
|
switch strings.ToUpper(strings.TrimSpace(s)) {
|
|
case "IDLE":
|
|
return StateIdle
|
|
case "PERMISSION":
|
|
return StatePermission
|
|
case "THINKING":
|
|
return StateThinking
|
|
case "WORKING":
|
|
return StateWorking
|
|
case "ERROR":
|
|
return StateError
|
|
default:
|
|
return StateUnknown
|
|
}
|
|
}
|