feat(scoring): transition workflow learning and fix history UX (#39)

- add transition scoring engine to learn sequential workflows per
directory using command skeletons
- prioritize current active git branch over older branches in
suggestions
- restore strict chronological order for history navigation and bypass
AI re-ranking
- add in-memory session history for instant access to just-run commands
- fix PTY prompt not syncing when using arrow keys in the history menu
- fix history tie-breaker to properly prioritize newer commands by
assigning larger IDs
- fix potential mutex deadlock between bufferMu and pty write during
history navigation
- fix workspace git detection to remove depth limits and correctly
identify repositories with detached HEADs
- fix SQLite connection leaks in frecency queries
- fix global state leaks in history tests by snapshotting and restoring
registry states
- modernize string prefix checks using strings.CutPrefix
This commit is contained in:
VERSE
2026-07-26 19:10:56 +07:00
committed by GitHub
parent 85ac76e85a
commit 6ffb9f83f2
20 changed files with 1004 additions and 72 deletions
+172 -2
View File
@@ -22,6 +22,14 @@ type FrecencyEntry struct {
RawScore float64
}
type TransitionEntry struct {
PrevSkeleton string
NextSkeleton string
Cwd string
Count int
LastUsed time.Time
}
type FrecencyStore struct {
db *sql.DB
mu sync.Mutex
@@ -51,6 +59,7 @@ func NewFrecencyStore(dbPath string) (*FrecencyStore, error) {
if err != nil {
return nil, fmt.Errorf("failed to open sqlite database: %w", err)
}
db.SetMaxOpenConns(1)
store := &FrecencyStore{db: db}
if err := store.initSchema(context.Background()); err != nil {
@@ -89,16 +98,29 @@ CREATE TABLE IF NOT EXISTS history_entries (
);
CREATE INDEX IF NOT EXISTS idx_history_cwd_cmd ON history_entries(cwd, cmd);
CREATE TABLE IF NOT EXISTS command_transitions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
prev_skeleton TEXT NOT NULL,
next_skeleton TEXT NOT NULL,
cwd TEXT NOT NULL,
count INTEGER DEFAULT 1,
last_used TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(prev_skeleton, next_skeleton, cwd)
);
CREATE INDEX IF NOT EXISTS idx_transitions_prev_cwd ON command_transitions(prev_skeleton, cwd);
`
_, err := f.db.ExecContext(ctxTimeout, schema)
return err
}
func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error {
func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string, exitCode int) error {
if f == nil {
return nil
}
cmd = strings.TrimSpace(cmd)
cwd = strings.TrimSpace(cwd)
if cmd == "" || cwd == "" {
return nil
}
@@ -112,17 +134,165 @@ func (f *FrecencyStore) Record(ctx context.Context, cmd, cwd string) error {
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
query := `
var query string
if exitCode == 0 {
query = `
INSERT INTO history_entries (cmd, cwd, count, last_used)
VALUES (?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(cmd, cwd) DO UPDATE SET
count = count + 1,
last_used = CURRENT_TIMESTAMP;
`
} else {
query = `
INSERT INTO history_entries (cmd, cwd, count, last_used)
VALUES (?, ?, 0, CURRENT_TIMESTAMP)
ON CONFLICT(cmd, cwd) DO UPDATE SET
last_used = CURRENT_TIMESTAMP;
`
}
_, err := f.db.ExecContext(ctxTimeout, query, cmd, cwd)
return err
}
func (f *FrecencyStore) RecordTransition(ctx context.Context, prevSkeleton, nextSkeleton, cwd string, nextExitCode int) error {
if f == nil {
return nil
}
prevSkeleton = strings.TrimSpace(prevSkeleton)
nextSkeleton = strings.TrimSpace(nextSkeleton)
cwd = strings.TrimSpace(cwd)
if prevSkeleton == "" || nextSkeleton == "" || cwd == "" {
return nil
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
var query string
if nextExitCode == 0 {
query = `
INSERT INTO command_transitions (prev_skeleton, next_skeleton, cwd, count, last_used)
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(prev_skeleton, next_skeleton, cwd) DO UPDATE SET
count = count + 1,
last_used = CURRENT_TIMESTAMP;
`
} else {
query = `
INSERT INTO command_transitions (prev_skeleton, next_skeleton, cwd, count, last_used)
VALUES (?, ?, ?, 0, CURRENT_TIMESTAMP)
ON CONFLICT(prev_skeleton, next_skeleton, cwd) DO UPDATE SET
last_used = CURRENT_TIMESTAMP;
`
}
_, err := f.db.ExecContext(ctxTimeout, query, prevSkeleton, nextSkeleton, cwd)
return err
}
func (f *FrecencyStore) QueryTransitionsWithFallback(ctx context.Context, prevSkeleton, cwd string) ([]TransitionEntry, bool) {
if f == nil {
return nil, false
}
prevSkeleton = strings.TrimSpace(prevSkeleton)
cwd = strings.TrimSpace(cwd)
if prevSkeleton == "" {
return nil, false
}
f.mu.Lock()
defer f.mu.Unlock()
if ctx == nil {
ctx = context.Background()
}
ctxTimeout, cancel := context.WithTimeout(ctx, 1000*time.Millisecond)
defer cancel()
// Phase 1: Local query with depth fallback
parts := strings.Fields(prevSkeleton)
for len(parts) > 0 {
key := strings.Join(parts, " ")
var loopEntries []TransitionEntry
func() {
rows, err := f.db.QueryContext(ctxTimeout, `
SELECT prev_skeleton, next_skeleton, cwd, count, last_used
FROM command_transitions
WHERE prev_skeleton = ? AND cwd = ? AND count > 0
ORDER BY count DESC
`, key, cwd)
if err == nil {
defer rows.Close()
for rows.Next() {
var prev, next, rCwd string
var count int
var lastUsedRaw string
if err := rows.Scan(&prev, &next, &rCwd, &count, &lastUsedRaw); err == nil {
t, _ := parseTimestamp(lastUsedRaw)
loopEntries = append(loopEntries, TransitionEntry{
PrevSkeleton: prev,
NextSkeleton: next,
Cwd: rCwd,
Count: count,
LastUsed: t,
})
}
}
}
}()
if len(loopEntries) > 0 {
return loopEntries, true
}
parts = parts[:len(parts)-1]
}
// Phase 2: Global query with depth fallback
parts = strings.Fields(prevSkeleton)
for len(parts) > 0 {
key := strings.Join(parts, " ")
var loopEntries []TransitionEntry
func() {
rows, err := f.db.QueryContext(ctxTimeout, `
SELECT prev_skeleton, next_skeleton, SUM(count) as total_count, MAX(last_used) as max_last_used
FROM command_transitions
WHERE prev_skeleton = ? AND count > 0
GROUP BY next_skeleton
ORDER BY total_count DESC
`, key)
if err == nil {
defer rows.Close()
for rows.Next() {
var prev, next string
var count int
var lastUsedRaw string
if err := rows.Scan(&prev, &next, &count, &lastUsedRaw); err == nil {
t, _ := parseTimestamp(lastUsedRaw)
loopEntries = append(loopEntries, TransitionEntry{
PrevSkeleton: prev,
NextSkeleton: next,
Cwd: "",
Count: count,
LastUsed: t,
})
}
}
}
}()
if len(loopEntries) > 0 {
return loopEntries, false
}
parts = parts[:len(parts)-1]
}
return nil, false
}
func (f *FrecencyStore) RawScore(count int, lastUsed time.Time) float64 {
if count <= 0 {
return 0