Add auto-summary settings
This commit is contained in:
463
internal/app/summarizer.go
Normal file
463
internal/app/summarizer.go
Normal file
@@ -0,0 +1,463 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user