feat: 支持类似claude code 会话历史的能力

This commit is contained in:
kite 2026-04-29 18:58:17 +08:00
parent edd6854f21
commit daa7181ce1
6 changed files with 337 additions and 122 deletions

View file

@ -103,7 +103,6 @@ type reviewOptions struct {
outputFormat string
concurrency int
perFileTimeout int
debug bool
showHelp bool
}
@ -121,7 +120,6 @@ func parseReviewFlags(args []string) (reviewOptions, error) {
a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json")
a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews")
a.IntVar(&opts.perFileTimeout, "timeout", 10, "per-file timeout in minutes")
a.BoolVar(&opts.debug, "debug", false, "enable debug mode (write session dump to temp/)")
if err := a.Parse(args); err != nil {
return opts, fmt.Errorf("parse flags: %w", err)
@ -182,7 +180,6 @@ Flags:`)
fs.StringVar(&d.commit, "commit", "", "single commit hash or tag to review (vs its parent) (shorthand: -c)")
fs.IntVar(&d.concurrency, "concurrency", 8, "max concurrent file reviews")
fs.IntVar(&d.perFileTimeout, "timeout", 10, "per-file timeout in minutes")
fs.BoolVar(&d.debug, "debug", false, "enable debug mode (write session dump to temp/)")
fs.PrintDefaults()
}

View file

@ -100,7 +100,6 @@ func runReview(args []string) error {
CommentWorkerPool: agent.NewCommentWorkerPool(opts.concurrency),
MaxConcurrency: opts.concurrency,
PerFileTimeoutMinutes: opts.perFileTimeout,
Debug: opts.debug,
Model: model,
})

BIN
dist/opencodereview vendored

Binary file not shown.

View file

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"sync/atomic"
@ -80,11 +81,8 @@ type Args struct {
// template phases (plan/memory_compression) don't specify one.
Model string
// Debug enables debug mode which writes a session dump to temp/.
Debug bool
// Session is an optional session history instance for collecting conversation records.
// When nil, a default one is created automatically.
// When nil, a default one is created automatically with git branch auto-detected from repoDir.
Session *session.SessionHistory
}
@ -159,9 +157,9 @@ func New(args Args) *Agent {
args.CommentCollector = tool.NewCommentCollector()
}
if args.Session == nil {
args.Session = session.New("", args.RepoDir)
gitBranch := detectGitBranch(args.RepoDir)
args.Session = session.New(args.RepoDir, gitBranch, args.Model)
}
args.Session.Debug = args.Debug
return &Agent{
args: args,
session: args.Session,
@ -797,3 +795,13 @@ func buildMessageXML(msgs []llm.Message) string {
return sb.String()
}
// detectGitBranch returns the current git branch name for the given repo, or empty string on failure.
func detectGitBranch(repoDir string) string {
cmd := exec.Command("git", "-C", repoDir, "rev-parse", "--abbrev-ref", "HEAD")
out, err := cmd.Output()
if err != nil || len(out) == 0 {
return ""
}
return strings.TrimSpace(string(out))
}

View file

@ -4,10 +4,7 @@
package session
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
@ -18,20 +15,22 @@ import (
type TaskType string
const (
PlanTask TaskType = "plan_task"
MainTask TaskType = "main_task"
PlanTask TaskType = "plan_task"
MainTask TaskType = "main_task"
MemoryCompressionTask TaskType = "memory_compression_task"
)
// SessionHistory is the top-level container for an entire CR run.
// It is safe for concurrent use by multiple goroutines.
type SessionHistory struct {
mu sync.Mutex
SessionID string
RepoDir string
StartTime time.Time
EndTime time.Time
Debug bool
mu sync.Mutex
SessionID string
RepoDir string
GitBranch string
Model string
StartTime time.Time
EndTime time.Time
persist *jsonlWriter
FileSessions map[string]*FileSession
}
@ -40,17 +39,19 @@ type FileSession struct {
mu sync.Mutex
FilePath string
TaskRecords map[TaskType][]*TaskRecord
session *SessionHistory // back-reference for JSONL persistence
}
// TaskRecord captures a single LLM request-response cycle within a file subtask.
type TaskRecord struct {
Type TaskType
RequestNo int // sequential number within this task type
RequestMessages []llm.Message // messages sent to LLM
Response *ResponseRecord
ToolResults []ToolResultRecord
Duration time.Duration
Error string
Type TaskType
RequestNo int // sequential number within this task type
RequestMessages []llm.Message // messages sent to LLM
Response *ResponseRecord
ToolResults []ToolResultRecord
Duration time.Duration
Error string
fileSession *FileSession // back-reference for JSONL persistence
}
// TokenUsage holds estimated token usage for a single LLM request/response cycle.
@ -77,14 +78,27 @@ type ToolResultRecord struct {
Result string
}
// New creates a new SessionHistory with the given session ID and repo directory.
func New(sessionID, repoDir string) *SessionHistory {
return &SessionHistory{
SessionID: sessionID,
RepoDir: repoDir,
StartTime: time.Now(),
// New creates a new SessionHistory with the given repo directory.
func New(repoDir, gitBranch, model string) *SessionHistory {
sessionID := generateUUID()
sh := &SessionHistory{
SessionID: sessionID,
RepoDir: repoDir,
GitBranch: gitBranch,
Model: model,
StartTime: time.Now(),
FileSessions: make(map[string]*FileSession),
}
p, err := newJSONLWriter(sessionID, repoDir, gitBranch, model)
if err != nil {
fmt.Printf("[ocr session] warning: failed to create session writer: %v\n", err)
} else {
sh.persist = p
p.WriteSessionStart(sh.StartTime)
}
return sh
}
// GetOrCreateFileSession returns the FileSession for the given file path,
@ -98,107 +112,34 @@ func (sh *SessionHistory) GetOrCreateFileSession(filePath string) *FileSession {
fs = &FileSession{
FilePath: filePath,
TaskRecords: make(map[TaskType][]*TaskRecord),
session: sh,
}
sh.FileSessions[filePath] = fs
}
return fs
}
// Finalize marks the session as complete and sets the end time.
// Finalize marks the session as complete, sets the end time, and persists
// the final summary record.
func (sh *SessionHistory) Finalize() {
sh.mu.Lock()
sh.EndTime = time.Now()
debug := sh.Debug
sh.mu.Unlock()
if debug {
sh.writeDebugDump()
}
}
// --- Debug Dump ---
type sessionSnapshot struct {
SessionID string `json:"session_id"`
RepoDir string `json:"repo_dir"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
FileSessions []*fileSessionSnapshot `json:"file_sessions"`
}
type fileSessionSnapshot struct {
FilePath string `json:"file_path"`
TaskRecords map[string][]*taskRecordSnapshot `json:"task_records"`
}
type taskRecordSnapshot struct {
Type string `json:"type"`
RequestNo int `json:"request_no"`
RequestMessages any `json:"request_messages"`
Response *ResponseRecord `json:"response"`
ToolResults []ToolResultRecord `json:"tool_results"`
Duration time.Duration `json:"duration"`
Error string `json:"error"`
}
func (sh *SessionHistory) writeDebugDump() {
sessionName := sh.SessionID
if sessionName == "" {
sessionName = fmt.Sprintf("unknown-%d", time.Now().Unix())
}
sh.mu.Lock()
snap := &sessionSnapshot{
SessionID: sh.SessionID,
RepoDir: sh.RepoDir,
StartTime: sh.StartTime,
EndTime: sh.EndTime,
FileSessions: make([]*fileSessionSnapshot, 0, len(sh.FileSessions)),
}
for _, fs := range sh.FileSessions {
fs.mu.Lock()
fss := &fileSessionSnapshot{
FilePath: fs.FilePath,
TaskRecords: make(map[string][]*taskRecordSnapshot),
}
for ttype, records := range fs.TaskRecords {
for _, rec := range records {
fss.TaskRecords[string(ttype)] = append(fss.TaskRecords[string(ttype)], &taskRecordSnapshot{
Type: string(rec.Type),
RequestNo: rec.RequestNo,
RequestMessages: rec.RequestMessages,
Response: rec.Response,
ToolResults: rec.ToolResults,
Duration: rec.Duration,
Error: rec.Error,
})
}
}
fs.mu.Unlock()
snap.FileSessions = append(snap.FileSessions, fss)
p := sh.persist
duration := sh.EndTime.Sub(sh.StartTime)
filesReviewed := make([]string, 0, len(sh.FileSessions))
for fp := range sh.FileSessions {
filesReviewed = append(filesReviewed, fp)
}
sh.mu.Unlock()
data, err := json.MarshalIndent(snap, "", " ")
if err != nil {
fmt.Printf("[ocr debug] Failed to marshal session history: %v\n", err)
return
}
debugDir := filepath.Join(sh.RepoDir, "temp")
if err := os.MkdirAll(debugDir, 0755); err != nil {
fmt.Printf("[ocr debug] Failed to create debug dir %s: %v\n", debugDir, err)
return
}
filename := filepath.Join(debugDir, fmt.Sprintf("ocr-session-%s.json", sessionName))
if err := os.WriteFile(filename, data, 0644); err != nil {
fmt.Printf("[ocr debug] Failed to write session dump to %s: %v\n", filename, err)
} else {
fmt.Printf("[ocr debug] Session history written to %s\n", filename)
if p != nil {
p.WriteSessionEnd(duration, filesReviewed)
}
}
// AppendTaskRecord adds a new task record to the file session for the given
// file path and task type. It auto-assigns the RequestNo based on existing records.
// file path and task type. It auto-assigns the RequestNo based on existing records
// and writes an llm_request record to the JSONL stream.
func (fs *FileSession) AppendTaskRecord(taskType TaskType, messages []llm.Message) *TaskRecord {
fs.mu.Lock()
defer fs.mu.Unlock()
@ -207,8 +148,14 @@ func (fs *FileSession) AppendTaskRecord(taskType TaskType, messages []llm.Messag
Type: taskType,
RequestNo: len(fs.TaskRecords[taskType]) + 1,
RequestMessages: copyMessages(messages),
fileSession: fs,
}
fs.TaskRecords[taskType] = append(fs.TaskRecords[taskType], rec)
if p := fs.session.persist; p != nil {
p.WriteLLMRequest(fs.FilePath, taskType, rec.RequestNo, copyMessagesForJSON(messages))
}
return rec
}
@ -227,8 +174,27 @@ func copyMessages(msgs []llm.Message) []llm.Message {
return cp
}
// copyMessagesForJSON produces a JSON-friendly slice for persistence.
func copyMessagesForJSON(msgs []llm.Message) any {
type msg struct {
Role string `json:"role"`
Content any `json:"content"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
out := make([]msg, 0, len(msgs))
for _, m := range msgs {
out = append(out, msg{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
})
}
return out
}
// SetResponse records the LLM response in the most recent TaskRecord of the given type.
// It computes estimated token usage locally using tiktoken, independent of any API format.
// It computes estimated token usage locally using tiktoken, independent of any API format,
// and writes an llm_response record to the JSONL stream.
func (tr *TaskRecord) SetResponse(resp *llm.ChatResponse, duration time.Duration) {
if resp == nil || len(resp.Choices) == 0 {
tr.Error = "empty response"
@ -260,6 +226,20 @@ func (tr *TaskRecord) SetResponse(resp *llm.ChatResponse, duration time.Duration
Usage: usage,
}
tr.Duration = duration
if fs := tr.fileSession; fs != nil {
if p := fs.session.persist; p != nil {
toolCallsJSON := make([]map[string]any, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
toolCallsJSON = append(toolCallsJSON, map[string]any{
"id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
})
}
p.WriteLLMResponse(fs.FilePath, tr.Type, content, toolCallsJSON, resp.Model, promptTokens, completionTokens, duration)
}
}
}
// SetError records an error for this task record.
@ -268,12 +248,18 @@ func (tr *TaskRecord) SetError(err error, duration time.Duration) {
tr.Duration = duration
}
// AddToolResult appends a tool call result to this task record.
// AddToolResult appends a tool call result to this task record and writes a
// tool_call record to the JSONL stream.
func (tr *TaskRecord) AddToolResult(toolName, arguments, result string) {
tr.ToolResults = append(tr.ToolResults, ToolResultRecord{
ToolName: toolName,
Arguments: arguments,
Result: result,
})
}
if fs := tr.fileSession; fs != nil {
if p := fs.session.persist; p != nil {
p.WriteToolCall(fs.FilePath, tr.Type, toolName, arguments, result, true, 0)
}
}
}

225
internal/session/persist.go Normal file
View file

@ -0,0 +1,225 @@
package session
import (
"bufio"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// jsonlWriter streams session records to a JSONL file under
// $HOME/.open-code-review/sessions/<encoded-repo-path>/<session-id>.jsonl.
// It is safe for concurrent use by multiple goroutines.
type jsonlWriter struct {
mu sync.Mutex
sessionID string
repoDir string
gitBranch string
model string
file *os.File
writer *bufio.Writer
lastUUID string // tracks chain of records via parentUuid
}
// newJSONLWriter creates and opens a new JSONL writer for the given session.
func newJSONLWriter(sessionID, repoDir, gitBranch, model string) (*jsonlWriter, error) {
jw := &jsonlWriter{
sessionID: sessionID,
repoDir: repoDir,
gitBranch: gitBranch,
model: model,
}
if err := jw.open(); err != nil {
return nil, err
}
return jw, nil
}
func generateUUID() string {
b := make([]byte, 16)
_, err := io.ReadFull(rand.Reader, b)
if err != nil {
// Fallback — extremely unlikely but keeps things working without panics.
return fmt.Sprintf("fallback-%d", time.Now().UnixNano())
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 1
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
func encodeRepoPath(p string) string {
p = strings.TrimPrefix(p, "/")
return strings.ReplaceAll(p, "/", "-")
}
func (jw *jsonlWriter) open() error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolve home dir: %w", err)
}
sessionDir := filepath.Join(home, ".open-code-review", "sessions", encodeRepoPath(jw.repoDir))
if err := os.MkdirAll(sessionDir, 0755); err != nil {
return fmt.Errorf("create session dir: %w", err)
}
filename := filepath.Join(sessionDir, jw.sessionID+".jsonl")
f, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("open session file: %w", err)
}
jw.file = f
jw.writer = bufio.NewWriter(f)
return nil
}
func (jw *jsonlWriter) writeRecordLocked(rec map[string]any) {
data, err := json.Marshal(rec)
if err != nil {
fmt.Printf("[ocr session] failed to marshal record: %v\n", err)
return
}
jw.writer.Write(data)
jw.writer.WriteByte('\n')
}
// WriteSessionStart writes the initial session_start record.
func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string {
uuid := generateUUID()
rec := map[string]any{
"uuid": uuid,
"parentUuid": nil,
"type": "session_start",
"sessionId": jw.sessionID,
"timestamp": startTime.UTC().Format(time.RFC3339),
"cwd": jw.repoDir,
"gitBranch": jw.gitBranch,
"model": jw.model,
}
jw.mu.Lock()
defer jw.mu.Unlock()
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteLLMRequest writes a request entry with the resolved messages.
func (jw *jsonlWriter) WriteLLMRequest(filePath string, taskType TaskType, requestNo int, messages any) string {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "llm_request",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"filePath": filePath,
"taskType": string(taskType),
"request_no": requestNo,
"messages": messages,
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteLLMResponse writes a response entry with model, content, tool calls, usage.
func (jw *jsonlWriter) WriteLLMResponse(filePath string, taskType TaskType, content string, toolCalls []map[string]any, model string, promptTokens, completionTokens int, duration time.Duration) string {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "llm_response",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"filePath": filePath,
"taskType": string(taskType),
"model": model,
"content": content,
"tool_calls": toolCalls,
"duration_ms": duration.Milliseconds(),
"usage": map[string]int{
"prompt_tokens": promptTokens,
"completion_tokens": completionTokens,
},
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteToolCall writes a tool call result entry.
func (jw *jsonlWriter) WriteToolCall(filePath string, taskType TaskType, toolName, arguments, result string, ok bool, duration time.Duration) string {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "tool_call",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"filePath": filePath,
"taskType": string(taskType),
"tool_name": toolName,
"arguments": arguments,
"result": result,
"ok": ok,
"duration_ms": duration.Milliseconds(),
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
return uuid
}
// WriteSessionEnd writes the final session_end summary record and closes the file.
func (jw *jsonlWriter) WriteSessionEnd(duration time.Duration, filesReviewed []string) {
uuid := generateUUID()
jw.mu.Lock()
defer jw.mu.Unlock()
rec := map[string]any{
"uuid": uuid,
"parentUuid": jw.lastUUID,
"type": "session_end",
"sessionId": jw.sessionID,
"timestamp": time.Now().UTC().Format(time.RFC3339),
"files_reviewed": filesReviewed,
"duration_seconds": duration.Seconds(),
}
jw.writeRecordLocked(rec)
jw.lastUUID = uuid
if jw.writer != nil {
jw.writer.Flush()
}
if jw.file != nil {
jw.file.Close()
}
}
func (jw *jsonlWriter) flushAndClose() {
jw.mu.Lock()
defer jw.mu.Unlock()
if jw.writer != nil {
jw.writer.Flush()
}
if jw.file != nil {
jw.file.Close()
}
}