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
Jason Hall 63b1b07c85
Document git notes integration for conversation logging
Add comprehensive documentation for the git notes functionality:
- Example usage and real-world scenarios
- Commands for viewing conversation context attached to commits
- Configuration options via .claude/notes.json
- Privacy controls and sensitive data filtering
- Team collaboration workflows for sharing git notes
- Complete JSON example showing structured conversation data

Users can now easily understand how to view and configure the
automatic conversation context attachment to their git commits.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 12:27:12 -04:00

241 lines
7.1 KiB
Go

package context
import (
"fmt"
"io"
"os"
"regexp"
"strings"
)
// 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"`
}
// 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
}
// NewContextExtractor creates a new context extractor with default settings
func NewContextExtractor() *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
}
return &ContextExtractor{
maxExcerptLength: 5000, // 5KB limit for context
sensitivePatterns: sensitivePatterns,
}
}
// ExtractRecentContext extracts recent conversation context from a transcript file
func (ce *ContextExtractor) ExtractRecentContext(transcriptPath string, sessionID string) (*ConversationContext, error) {
if transcriptPath == "" {
return &ConversationContext{}, nil
}
file, err := os.Open(transcriptPath)
if err != nil {
// Transcript might not exist, return empty context
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)
// Apply privacy filters
context = ce.filterSensitiveContent(context)
return context, nil
}
// parseTranscriptContent parses transcript content and extracts conversation elements
func (ce *ContextExtractor) parseTranscriptContent(content, sessionID string) *ConversationContext {
context := &ConversationContext{
UserPrompts: []string{},
ClaudeResponses: []string{},
ToolInteractions: []ToolInteraction{},
}
lines := strings.Split(content, "\n")
currentMessage := ""
messageType := ""
for _, line := range lines {
line = strings.TrimSpace(line)
// Skip empty lines
if line == "" {
continue
}
// Detect message boundaries and types
if strings.HasPrefix(line, "Human:") || strings.HasPrefix(line, "User:") {
// Save previous message if exists
if currentMessage != "" && messageType == "claude" {
context.ClaudeResponses = append(context.ClaudeResponses, strings.TrimSpace(currentMessage))
}
messageType = "human"
currentMessage = strings.TrimPrefix(strings.TrimPrefix(line, "Human:"), "User:")
} else if strings.HasPrefix(line, "Assistant:") || strings.HasPrefix(line, "Claude:") {
// Save previous message if exists
if currentMessage != "" && messageType == "human" {
context.UserPrompts = append(context.UserPrompts, strings.TrimSpace(currentMessage))
}
messageType = "claude"
currentMessage = strings.TrimPrefix(strings.TrimPrefix(line, "Assistant:"), "Claude:")
} else if strings.Contains(line, "tool_use") || strings.Contains(line, "function_calls") {
// Try to parse tool interactions
toolInteraction := ce.parseToolInteraction(line)
if toolInteraction != nil {
context.ToolInteractions = append(context.ToolInteractions, *toolInteraction)
}
} else {
// Continue building current message
if currentMessage != "" {
currentMessage += "\n"
}
currentMessage += line
}
}
// Don't forget the last message
if currentMessage != "" {
if messageType == "human" {
context.UserPrompts = append(context.UserPrompts, strings.TrimSpace(currentMessage))
} else if messageType == "claude" {
context.ClaudeResponses = append(context.ClaudeResponses, strings.TrimSpace(currentMessage))
}
}
return context
}
// parseToolInteraction attempts to parse tool interaction from a line
func (ce *ContextExtractor) parseToolInteraction(line string) *ToolInteraction {
// This is a simplified parser - in reality, the transcript format may vary
if strings.Contains(line, "Bash") && strings.Contains(line, "command") {
return &ToolInteraction{
Tool: "Bash",
Input: ce.extractBetween(line, "command", "description"),
Output: "Command executed",
}
}
if strings.Contains(line, "Edit") || strings.Contains(line, "Write") {
return &ToolInteraction{
Tool: "File",
Input: ce.extractBetween(line, "file_path", "content"),
Output: "File modified",
}
}
return nil
}
// extractBetween extracts text between two markers
func (ce *ContextExtractor) extractBetween(text, start, end string) string {
startIdx := strings.Index(text, start)
if startIdx == -1 {
return ""
}
endIdx := strings.Index(text[startIdx:], end)
if endIdx == -1 {
return text[startIdx:]
}
return text[startIdx : startIdx+endIdx]
}
// 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 {
var parts []string
// Include recent user prompts (last 2)
if len(context.UserPrompts) > 0 {
start := len(context.UserPrompts) - 2
if start < 0 {
start = 0
}
parts = append(parts, "Recent user prompts:")
for i := start; i < len(context.UserPrompts); i++ {
prompt := context.UserPrompts[i]
if len(prompt) > 200 {
prompt = prompt[:197] + "..."
}
parts = append(parts, fmt.Sprintf("- %s", prompt))
}
}
// Include recent tool interactions
if len(context.ToolInteractions) > 0 {
parts = append(parts, "\nTool interactions:")
for _, interaction := range context.ToolInteractions {
parts = append(parts, fmt.Sprintf("- %s: %s", interaction.Tool, interaction.Input))
}
}
excerpt := strings.Join(parts, "\n")
// Truncate if too long
if len(excerpt) > ce.maxExcerptLength {
excerpt = excerpt[:ce.maxExcerptLength-3] + "..."
}
return excerpt
}