1
0
Fork 0
mirror of https://github.com/imjasonh/cnotes synced 2026-07-08 17:16:00 +00:00
cnotes/internal/context/conversation.go

432 lines
13 KiB
Go

package context
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/imjasonh/cnotes/internal/config"
)
// ConversationContext represents relevant conversation context for a commit
type ConversationContext struct {
UserPrompts []string `json:"user_prompts"`
ClaudeResponses []string `json:"claude_responses"`
ToolInteractions []ToolInteraction `json:"tool_interactions"`
Events []ConversationEvent `json:"events"` // New: chronological events
LastEventTime time.Time `json:"last_event_time"` // Track the latest event timestamp
}
// ConversationEvent represents any event in the conversation
type ConversationEvent struct {
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"` // "user", "assistant", "tool", "system"
Content string `json:"content"`
ToolName string `json:"tool_name,omitempty"`
}
// ToolInteraction represents a tool use and its result
type ToolInteraction struct {
Tool string `json:"tool"`
Input string `json:"input"`
Output string `json:"output"`
Duration string `json:"duration,omitempty"`
}
// ContextExtractor extracts relevant conversation context from transcripts
type ContextExtractor struct {
maxExcerptLength int
sensitivePatterns []*regexp.Regexp
config *config.NotesConfig
}
// NewContextExtractor creates a new context extractor with default settings
func NewContextExtractor(cfg *config.NotesConfig) *ContextExtractor {
// Patterns to filter out sensitive information
sensitivePatterns := []*regexp.Regexp{
regexp.MustCompile(`(?i)(password|token|key|secret)[:\s]*[^\s\n]+`),
regexp.MustCompile(`(?i)(api[_-]?key)[:\s]*[^\s\n]+`),
regexp.MustCompile(`-----BEGIN [A-Z ]+-----`), // Private keys
regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`), // Base64 encoded secrets
}
maxLength := 5000
if cfg != nil && cfg.MaxExcerptLength > 0 {
maxLength = cfg.MaxExcerptLength
}
return &ContextExtractor{
maxExcerptLength: maxLength,
sensitivePatterns: sensitivePatterns,
config: cfg,
}
}
// ExtractRecentContext extracts recent conversation context from a transcript file
func (ce *ContextExtractor) ExtractRecentContext(transcriptPath string, sessionID string) (*ConversationContext, error) {
return ce.ExtractContextSince(transcriptPath, sessionID, time.Time{})
}
// ExtractContextSince extracts conversation context since a given timestamp
func (ce *ContextExtractor) ExtractContextSince(transcriptPath string, sessionID string, since time.Time) (*ConversationContext, error) {
if transcriptPath == "" {
return &ConversationContext{}, nil
}
// Get the directory containing transcripts
transcriptDir := filepath.Dir(transcriptPath)
// Initialize combined context
combinedContext := &ConversationContext{
UserPrompts: []string{},
ClaudeResponses: []string{},
ToolInteractions: []ToolInteraction{},
Events: []ConversationEvent{},
}
// Read all transcript files in the directory
files, err := os.ReadDir(transcriptDir)
if err != nil {
// If we can't read the directory, fall back to just the current transcript
return ce.extractFromSingleTranscript(transcriptPath, sessionID, since)
}
// Process each transcript file
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".jsonl") {
continue
}
filePath := filepath.Join(transcriptDir, file.Name())
context, err := ce.extractFromSingleTranscript(filePath, "", since) // Empty sessionID to get all sessions
if err != nil {
continue // Skip files that can't be read
}
// Merge contexts
combinedContext.UserPrompts = append(combinedContext.UserPrompts, context.UserPrompts...)
combinedContext.ClaudeResponses = append(combinedContext.ClaudeResponses, context.ClaudeResponses...)
combinedContext.ToolInteractions = append(combinedContext.ToolInteractions, context.ToolInteractions...)
combinedContext.Events = append(combinedContext.Events, context.Events...)
}
// Apply privacy filters
combinedContext = ce.filterSensitiveContent(combinedContext)
return combinedContext, nil
}
// extractFromSingleTranscript extracts context from a single transcript file
func (ce *ContextExtractor) extractFromSingleTranscript(transcriptPath string, sessionID string, since time.Time) (*ConversationContext, error) {
file, err := os.Open(transcriptPath)
if err != nil {
return &ConversationContext{}, nil
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil, fmt.Errorf("failed to read transcript: %w", err)
}
// Parse the transcript content
context := ce.parseTranscriptContent(string(content), sessionID, since)
return context, nil
}
// parseTranscriptContent parses transcript content and extracts conversation elements
func (ce *ContextExtractor) parseTranscriptContent(content, sessionID string, since time.Time) *ConversationContext {
context := &ConversationContext{
UserPrompts: []string{},
ClaudeResponses: []string{},
ToolInteractions: []ToolInteraction{},
Events: []ConversationEvent{},
}
lines := strings.Split(content, "\n")
// Parse JSONL format
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Parse each line as JSON
var entry map[string]interface{}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
continue // Skip invalid JSON lines
}
// Only process entries for the current session (unless sessionID is empty)
if sessionID != "" {
entrySessionID, _ := entry["sessionId"].(string)
if entrySessionID != "" && entrySessionID != sessionID {
continue
}
}
// Extract timestamp
var entryTime time.Time
if timestampStr, ok := entry["timestamp"].(string); ok {
entryTime, _ = time.Parse(time.RFC3339, timestampStr)
}
// Filter by timestamp if provided
if !since.IsZero() && !entryTime.IsZero() && entryTime.Before(since) {
continue // Skip entries before the cutoff
}
// Extract based on type
entryType, _ := entry["type"].(string)
switch entryType {
case "user":
// Extract user prompts
if msg, ok := entry["message"].(map[string]interface{}); ok {
// Handle both string content and array content formats
if content, ok := msg["content"].(string); ok && content != "" {
// Direct string content
if !strings.Contains(content, "[Request interrupted by user") {
context.UserPrompts = append(context.UserPrompts, content)
// Add to events
context.Events = append(context.Events, ConversationEvent{
Timestamp: entryTime,
Type: "user",
Content: content,
})
}
} else if contentArray, ok := msg["content"].([]interface{}); ok {
// Array of content objects
for _, c := range contentArray {
if textContent, ok := c.(map[string]interface{}); ok {
if text, ok := textContent["text"].(string); ok && text != "" {
// Skip system messages about interruptions
if !strings.Contains(text, "[Request interrupted by user") {
context.UserPrompts = append(context.UserPrompts, text)
// Add to events
context.Events = append(context.Events, ConversationEvent{
Timestamp: entryTime,
Type: "user",
Content: text,
})
}
}
}
}
}
}
case "assistant":
// Extract tool uses and text responses from assistant messages
if msg, ok := entry["message"].(map[string]interface{}); ok {
if content, ok := msg["content"].([]interface{}); ok {
for _, c := range content {
if contentItem, ok := c.(map[string]interface{}); ok {
contentType, _ := contentItem["type"].(string)
switch contentType {
case "text":
// Assistant text response
if text, ok := contentItem["text"].(string); ok && text != "" {
context.ClaudeResponses = append(context.ClaudeResponses, text)
// Add to events
context.Events = append(context.Events, ConversationEvent{
Timestamp: entryTime,
Type: "assistant",
Content: text,
})
}
case "tool_use":
// Tool use
toolName, _ := contentItem["name"].(string)
if input, ok := contentItem["input"].(map[string]interface{}); ok {
interaction := ToolInteraction{
Tool: toolName,
}
// Extract key information based on tool type
switch toolName {
case "Bash":
if cmd, ok := input["command"].(string); ok {
interaction.Input = cmd
}
case "Write", "Edit", "MultiEdit":
if path, ok := input["file_path"].(string); ok {
interaction.Input = path
}
case "Read":
if path, ok := input["file_path"].(string); ok {
interaction.Input = path
}
case "WebFetch":
if url, ok := input["url"].(string); ok {
interaction.Input = url
}
default:
// For other tools, try to get a meaningful representation
if bytes, err := json.Marshal(input); err == nil {
interaction.Input = string(bytes)
}
}
if interaction.Input != "" {
context.ToolInteractions = append(context.ToolInteractions, interaction)
// Add to events
context.Events = append(context.Events, ConversationEvent{
Timestamp: entryTime,
Type: "tool",
Content: interaction.Input,
ToolName: toolName,
})
}
}
}
}
}
}
}
case "tool_result":
// Extract tool results
if result, ok := entry["result"].(map[string]interface{}); ok {
var resultContent string
toolName, _ := entry["tool_name"].(string)
if stdout, ok := result["stdout"].(string); ok && stdout != "" {
resultContent = stdout
} else if output, ok := result["output"].(string); ok && output != "" {
resultContent = output
}
if resultContent != "" {
// Add to events
context.Events = append(context.Events, ConversationEvent{
Timestamp: entryTime,
Type: "tool_result",
Content: resultContent,
ToolName: toolName,
})
}
}
}
}
// Track the last event time from all events
for _, event := range context.Events {
if event.Timestamp.After(context.LastEventTime) {
context.LastEventTime = event.Timestamp
}
}
return context
}
// filterSensitiveContent removes sensitive information from context
func (ce *ContextExtractor) filterSensitiveContent(context *ConversationContext) *ConversationContext {
// Filter user prompts
for i, prompt := range context.UserPrompts {
context.UserPrompts[i] = ce.sanitizeText(prompt)
}
// Filter Claude responses
for i, response := range context.ClaudeResponses {
context.ClaudeResponses[i] = ce.sanitizeText(response)
}
// Filter tool interactions
for i, interaction := range context.ToolInteractions {
context.ToolInteractions[i].Input = ce.sanitizeText(interaction.Input)
context.ToolInteractions[i].Output = ce.sanitizeText(interaction.Output)
}
return context
}
// sanitizeText removes sensitive patterns from text
func (ce *ContextExtractor) sanitizeText(text string) string {
for _, pattern := range ce.sensitivePatterns {
text = pattern.ReplaceAllString(text, "[REDACTED]")
}
return text
}
// CreateExcerpt creates a concise excerpt from conversation context
func (ce *ContextExtractor) CreateExcerpt(context *ConversationContext) string {
// Sort events by timestamp
sort.Slice(context.Events, func(i, j int) bool {
return context.Events[i].Timestamp.Before(context.Events[j].Timestamp)
})
var parts []string
for _, event := range context.Events {
var line string
switch event.Type {
case "user":
// Format user prompts
content := event.Content
if len(content) > 200 {
content = content[:197] + "..."
}
emoji := "👤"
if ce.config != nil && ce.config.UserEmoji != "" {
emoji = ce.config.UserEmoji
}
line = fmt.Sprintf("%s User: %s", emoji, content)
case "assistant":
// Format assistant responses
content := event.Content
if len(content) > 200 {
content = content[:197] + "..."
}
emoji := "🤖"
if ce.config != nil && ce.config.AssistantEmoji != "" {
emoji = ce.config.AssistantEmoji
}
line = fmt.Sprintf("%s Claude: %s", emoji, content)
case "tool":
// Format tool uses
content := event.Content
if len(content) > 150 {
content = content[:147] + "..."
}
line = fmt.Sprintf("Tool (%s): %s", event.ToolName, content)
case "tool_result":
// Format tool results - show abbreviated output
content := event.Content
lines := strings.Split(content, "\n")
if len(lines) > 3 {
content = strings.Join(lines[:3], "\n") + "\n[...]"
} else if len(content) > 150 {
content = content[:147] + "..."
}
line = fmt.Sprintf("Result: %s", content)
}
if line != "" {
parts = append(parts, line)
}
}
excerpt := strings.Join(parts, "\n\n")
// Truncate if too long
if len(excerpt) > ce.maxExcerptLength {
excerpt = excerpt[:ce.maxExcerptLength-3] + "..."
}
return excerpt
}