mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-10-20 06:27:50 +00:00
feat(chunking): add AST-aware code chunking for Go, Python, TypeScript
- [x] Add language-specific chunkers with AST parsing (Go, Python, TypeScript) - [x] Implement chunking manager to dispatch files to appropriate chunkers - [x] Integrate code chunks into vector sync for semantic search - [x] Add tree-sitter dependency for Python/TypeScript parsing - [x] Reorder struct fields for consistency across codebase - [x] Rename error variables to follow Go conventions (err → unmarshalErr, etc.) - [x] Add code chunk metadata to vector documents (language, symbol name, line ranges) - [x] Update worker service to initialize chunking pipeline with all three languages
This commit is contained in:
@@ -23,12 +23,12 @@ type Input struct {
|
|||||||
|
|
||||||
// Observation represents an observation from the API.
|
// Observation represents an observation from the API.
|
||||||
type Observation struct {
|
type Observation struct {
|
||||||
ID int64 `json:"id"`
|
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Subtitle string `json:"subtitle"`
|
Subtitle string `json:"subtitle"`
|
||||||
Narrative string `json:"narrative"`
|
Narrative string `json:"narrative"`
|
||||||
Facts []string `json:"facts"`
|
Facts []string `json:"facts"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -46,8 +46,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var input Input
|
var input Input
|
||||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
if unmarshalErr := json.Unmarshal(inputData, &input); unmarshalErr != nil {
|
||||||
hooks.WriteError("SessionStart", err)
|
hooks.WriteError("SessionStart", unmarshalErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,21 +44,21 @@ type StatusInput struct {
|
|||||||
|
|
||||||
// WorkerStats is the response from the worker's /api/stats endpoint.
|
// WorkerStats is the response from the worker's /api/stats endpoint.
|
||||||
type WorkerStats struct {
|
type WorkerStats struct {
|
||||||
Uptime string `json:"uptime"`
|
Uptime string `json:"uptime"`
|
||||||
ActiveSessions int `json:"activeSessions"`
|
Project string `json:"project,omitempty"`
|
||||||
QueueDepth int `json:"queueDepth"`
|
Retrieval struct {
|
||||||
IsProcessing bool `json:"isProcessing"`
|
|
||||||
ConnectedClients int `json:"connectedClients"`
|
|
||||||
SessionsToday int `json:"sessionsToday"`
|
|
||||||
Ready bool `json:"ready"`
|
|
||||||
Project string `json:"project,omitempty"`
|
|
||||||
ProjectObservations int `json:"projectObservations,omitempty"`
|
|
||||||
Retrieval struct {
|
|
||||||
TotalRequests int64 `json:"TotalRequests"`
|
TotalRequests int64 `json:"TotalRequests"`
|
||||||
ObservationsServed int64 `json:"ObservationsServed"`
|
ObservationsServed int64 `json:"ObservationsServed"`
|
||||||
SearchRequests int64 `json:"SearchRequests"`
|
SearchRequests int64 `json:"SearchRequests"`
|
||||||
ContextInjections int64 `json:"ContextInjections"`
|
ContextInjections int64 `json:"ContextInjections"`
|
||||||
} `json:"retrieval"`
|
} `json:"retrieval"`
|
||||||
|
ActiveSessions int `json:"activeSessions"`
|
||||||
|
QueueDepth int `json:"queueDepth"`
|
||||||
|
ConnectedClients int `json:"connectedClients"`
|
||||||
|
SessionsToday int `json:"sessionsToday"`
|
||||||
|
ProjectObservations int `json:"projectObservations,omitempty"`
|
||||||
|
IsProcessing bool `json:"isProcessing"`
|
||||||
|
Ready bool `json:"ready"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ANSI color codes
|
// ANSI color codes
|
||||||
|
|||||||
@@ -14,17 +14,17 @@ import (
|
|||||||
// Input is the hook input from Claude Code.
|
// Input is the hook input from Claude Code.
|
||||||
type Input struct {
|
type Input struct {
|
||||||
hooks.BaseInput
|
hooks.BaseInput
|
||||||
StopHookActive bool `json:"stop_hook_active"`
|
|
||||||
TranscriptPath string `json:"transcript_path"`
|
TranscriptPath string `json:"transcript_path"`
|
||||||
|
StopHookActive bool `json:"stop_hook_active"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TranscriptMessage represents a message in the transcript JSONL file.
|
// TranscriptMessage represents a message in the transcript JSONL file.
|
||||||
type TranscriptMessage struct {
|
type TranscriptMessage struct {
|
||||||
Type string `json:"type"`
|
|
||||||
Message struct {
|
Message struct {
|
||||||
|
Content any `json:"content"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content any `json:"content"` // Can be string or array
|
|
||||||
} `json:"message"`
|
} `json:"message"`
|
||||||
|
Type string `json:"type"` // Can be string or array
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractTextContent extracts text content from message content (handles both string and array formats).
|
// extractTextContent extracts text content from message content (handles both string and array formats).
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var input Input
|
var input Input
|
||||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
if unmarshalErr := json.Unmarshal(inputData, &input); unmarshalErr != nil {
|
||||||
hooks.WriteError("UserPromptSubmit", err)
|
hooks.WriteError("UserPromptSubmit", unmarshalErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ require (
|
|||||||
github.com/goccy/go-json v0.10.5
|
github.com/goccy/go-json v0.10.5
|
||||||
github.com/mattn/go-sqlite3 v1.14.32
|
github.com/mattn/go-sqlite3 v1.14.32
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/sugarme/tokenizer v0.3.0
|
github.com/sugarme/tokenizer v0.3.0
|
||||||
github.com/yalue/onnxruntime_go v1.25.0
|
github.com/yalue/onnxruntime_go v1.25.0
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
|||||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
github.com/schollz/progressbar/v2 v2.15.0 h1:dVzHQ8fHRmtPjD3K10jT3Qgn/+H+92jhPrhmxIJfDz8=
|
||||||
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
github.com/schollz/progressbar/v2 v2.15.0/go.mod h1:UdPq3prGkfQ7MOzZKlDRpYKcFqEMczbD7YmbPgpzKMI=
|
||||||
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82 h1:6C8qej6f1bStuePVkLSFxoU22XBS165D3klxlzRg8F4=
|
||||||
|
github.com/smacker/go-tree-sitter v0.0.0-20240827094217-dd81d9e9be82/go.mod h1:xe4pgH49k4SsmkQq5OT8abwhWmnzkhpgnXeekbx2efw=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// Package golang provides AST-aware chunking for Go source files.
|
||||||
|
package golang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunker implements AST-aware chunking for Go files.
|
||||||
|
type Chunker struct {
|
||||||
|
options chunking.ChunkOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChunker creates a new Go chunker.
|
||||||
|
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
||||||
|
return &Chunker{options: options}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language returns the language this chunker supports.
|
||||||
|
func (c *Chunker) Language() chunking.Language {
|
||||||
|
return chunking.LanguageGo
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedExtensions returns the file extensions this chunker handles.
|
||||||
|
func (c *Chunker) SupportedExtensions() []string {
|
||||||
|
return []string{".go"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk parses a Go source file and returns semantic code chunks.
|
||||||
|
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
||||||
|
// Read file content
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the Go file
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
file, err := parser.ParseFile(fset, filePath, content, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse Go file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks := make([]chunking.Chunk, 0)
|
||||||
|
sourceLines := strings.Split(string(content), "\n")
|
||||||
|
|
||||||
|
// Extract chunks from declarations
|
||||||
|
for _, decl := range file.Decls {
|
||||||
|
switch d := decl.(type) {
|
||||||
|
case *ast.FuncDecl:
|
||||||
|
chunk := c.extractFunction(fset, d, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
chunks = append(chunks, *chunk)
|
||||||
|
}
|
||||||
|
case *ast.GenDecl:
|
||||||
|
extracted := c.extractGenDecl(fset, d, sourceLines, filePath)
|
||||||
|
chunks = append(chunks, extracted...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunction extracts a function or method declaration as a chunk.
|
||||||
|
func (c *Chunker) extractFunction(fset *token.FileSet, fn *ast.FuncDecl, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
// Skip unexported if configured
|
||||||
|
if !c.options.IncludePrivate && !fn.Name.IsExported() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startPos := fset.Position(fn.Pos())
|
||||||
|
endPos := fset.Position(fn.End())
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageGo,
|
||||||
|
Name: fn.Name.Name,
|
||||||
|
StartLine: startPos.Line,
|
||||||
|
EndLine: endPos.Line,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is a method or a function
|
||||||
|
if fn.Recv != nil && len(fn.Recv.List) > 0 {
|
||||||
|
chunk.Type = chunking.ChunkTypeMethod
|
||||||
|
chunk.ParentName = c.extractReceiverType(fn.Recv)
|
||||||
|
} else {
|
||||||
|
chunk.Type = chunking.ChunkTypeFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content
|
||||||
|
chunk.Content = c.extractLines(sourceLines, startPos.Line, endPos.Line)
|
||||||
|
|
||||||
|
// Extract signature (function declaration without body)
|
||||||
|
chunk.Signature = c.extractFunctionSignature(fn, fset, sourceLines)
|
||||||
|
|
||||||
|
// Extract doc comment
|
||||||
|
if c.options.IncludeDocComments && fn.Doc != nil {
|
||||||
|
chunk.DocComment = strings.TrimSpace(fn.Doc.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGenDecl extracts general declarations (type, const, var).
|
||||||
|
func (c *Chunker) extractGenDecl(fset *token.FileSet, gd *ast.GenDecl, sourceLines []string, filePath string) []chunking.Chunk {
|
||||||
|
var chunks []chunking.Chunk
|
||||||
|
|
||||||
|
for _, spec := range gd.Specs {
|
||||||
|
switch s := spec.(type) {
|
||||||
|
case *ast.TypeSpec:
|
||||||
|
chunk := c.extractTypeSpec(fset, gd, s, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
chunks = append(chunks, *chunk)
|
||||||
|
}
|
||||||
|
case *ast.ValueSpec:
|
||||||
|
// Handle const and var declarations
|
||||||
|
chunk := c.extractValueSpec(fset, gd, s, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
chunks = append(chunks, *chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTypeSpec extracts a type declaration (struct, interface, type alias).
|
||||||
|
func (c *Chunker) extractTypeSpec(fset *token.FileSet, gd *ast.GenDecl, ts *ast.TypeSpec, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
// Skip unexported if configured
|
||||||
|
if !c.options.IncludePrivate && !ts.Name.IsExported() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startPos := fset.Position(gd.Pos())
|
||||||
|
endPos := fset.Position(gd.End())
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageGo,
|
||||||
|
Name: ts.Name.Name,
|
||||||
|
StartLine: startPos.Line,
|
||||||
|
EndLine: endPos.Line,
|
||||||
|
Content: c.extractLines(sourceLines, startPos.Line, endPos.Line),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine chunk type based on type expression
|
||||||
|
switch ts.Type.(type) {
|
||||||
|
case *ast.StructType:
|
||||||
|
chunk.Type = chunking.ChunkTypeClass // Treat struct as class
|
||||||
|
case *ast.InterfaceType:
|
||||||
|
chunk.Type = chunking.ChunkTypeInterface
|
||||||
|
default:
|
||||||
|
chunk.Type = chunking.ChunkTypeType
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract doc comment
|
||||||
|
if c.options.IncludeDocComments && gd.Doc != nil {
|
||||||
|
chunk.DocComment = strings.TrimSpace(gd.Doc.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractValueSpec extracts const or var declarations.
|
||||||
|
func (c *Chunker) extractValueSpec(fset *token.FileSet, gd *ast.GenDecl, vs *ast.ValueSpec, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
// Skip if all names are unexported and we're excluding private
|
||||||
|
if !c.options.IncludePrivate {
|
||||||
|
allUnexported := true
|
||||||
|
for _, name := range vs.Names {
|
||||||
|
if name.IsExported() {
|
||||||
|
allUnexported = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allUnexported {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startPos := fset.Position(gd.Pos())
|
||||||
|
endPos := fset.Position(gd.End())
|
||||||
|
|
||||||
|
// Use first name as the chunk name, join multiple if present
|
||||||
|
names := make([]string, len(vs.Names))
|
||||||
|
for i, name := range vs.Names {
|
||||||
|
names[i] = name.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageGo,
|
||||||
|
Name: strings.Join(names, ", "),
|
||||||
|
StartLine: startPos.Line,
|
||||||
|
EndLine: endPos.Line,
|
||||||
|
Content: c.extractLines(sourceLines, startPos.Line, endPos.Line),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set type based on token
|
||||||
|
if gd.Tok == token.CONST {
|
||||||
|
chunk.Type = chunking.ChunkTypeConst
|
||||||
|
} else {
|
||||||
|
chunk.Type = chunking.ChunkTypeVar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract doc comment
|
||||||
|
if c.options.IncludeDocComments && gd.Doc != nil {
|
||||||
|
chunk.DocComment = strings.TrimSpace(gd.Doc.Text())
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractReceiverType extracts the receiver type name from a method.
|
||||||
|
func (c *Chunker) extractReceiverType(recv *ast.FieldList) string {
|
||||||
|
if len(recv.List) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
field := recv.List[0]
|
||||||
|
switch t := field.Type.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return t.Name
|
||||||
|
case *ast.StarExpr:
|
||||||
|
if ident, ok := t.X.(*ast.Ident); ok {
|
||||||
|
return ident.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunctionSignature extracts the function signature without the body.
|
||||||
|
func (c *Chunker) extractFunctionSignature(fn *ast.FuncDecl, fset *token.FileSet, sourceLines []string) string {
|
||||||
|
if fn.Body == nil {
|
||||||
|
// No body, return entire declaration
|
||||||
|
startPos := fset.Position(fn.Pos())
|
||||||
|
endPos := fset.Position(fn.End())
|
||||||
|
return c.extractLines(sourceLines, startPos.Line, endPos.Line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract from start of function to just before body
|
||||||
|
startPos := fset.Position(fn.Pos())
|
||||||
|
bodyPos := fset.Position(fn.Body.Pos())
|
||||||
|
|
||||||
|
// If body is on the same line, extract just that line up to the opening brace
|
||||||
|
if startPos.Line == bodyPos.Line {
|
||||||
|
line := sourceLines[startPos.Line-1]
|
||||||
|
// Find the opening brace position
|
||||||
|
if idx := strings.Index(line[startPos.Column-1:], "{"); idx >= 0 {
|
||||||
|
return strings.TrimSpace(line[startPos.Column-1 : startPos.Column-1+idx])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(line[startPos.Column-1:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get lines from start to the line containing the opening brace
|
||||||
|
sig := c.extractLines(sourceLines, startPos.Line, bodyPos.Line)
|
||||||
|
// Remove the opening brace and anything after it
|
||||||
|
if idx := strings.Index(sig, "{"); idx >= 0 {
|
||||||
|
sig = sig[:idx]
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
||||||
|
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
||||||
|
if start < 1 || end < start || start > len(lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust for 0-indexed array (start and end are 1-indexed)
|
||||||
|
startIdx := start - 1
|
||||||
|
endIdx := end
|
||||||
|
if endIdx > len(lines) {
|
||||||
|
endIdx = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines[startIdx:endIdx], "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package golang
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoChunker_BasicFunctions(t *testing.T) {
|
||||||
|
// Create temp test file
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.go")
|
||||||
|
|
||||||
|
testCode := `package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// Greet prints a greeting message
|
||||||
|
func Greet(name string) {
|
||||||
|
fmt.Printf("Hello, %s!\n", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add adds two numbers
|
||||||
|
func Add(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
|
||||||
|
// unexported function should be included by default
|
||||||
|
func helper() string {
|
||||||
|
return "helper"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create chunker with default options
|
||||||
|
chunker := NewChunker(chunking.DefaultChunkOptions())
|
||||||
|
|
||||||
|
// Chunk the file
|
||||||
|
chunks, err := chunker.Chunk(context.Background(), testFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chunk() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we got all functions
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Errorf("Expected 3 chunks (Greet, Add, helper), got %d", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify chunk details
|
||||||
|
expectedNames := map[string]bool{
|
||||||
|
"Greet": false,
|
||||||
|
"Add": false,
|
||||||
|
"helper": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
if chunk.Type != chunking.ChunkTypeFunction {
|
||||||
|
t.Errorf("Expected chunk type 'function', got '%s'", chunk.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk.Language != chunking.LanguageGo {
|
||||||
|
t.Errorf("Expected language 'go', got '%s'", chunk.Language)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := expectedNames[chunk.Name]; !ok {
|
||||||
|
t.Errorf("Unexpected function name: %s", chunk.Name)
|
||||||
|
} else {
|
||||||
|
expectedNames[chunk.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify content is non-empty
|
||||||
|
if chunk.Content == "" {
|
||||||
|
t.Errorf("Chunk %s has empty content", chunk.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature is present for functions
|
||||||
|
if chunk.Signature == "" {
|
||||||
|
t.Errorf("Chunk %s has empty signature", chunk.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all expected functions were found
|
||||||
|
for name, found := range expectedNames {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected function %s not found", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoChunker_StructsAndMethods(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.go")
|
||||||
|
|
||||||
|
testCode := `package main
|
||||||
|
|
||||||
|
// User represents a user
|
||||||
|
type User struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetName returns the user's name
|
||||||
|
func (u *User) GetName() string {
|
||||||
|
return u.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetName sets the user's name
|
||||||
|
func (u *User) SetName(name string) {
|
||||||
|
u.Name = name
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunker := NewChunker(chunking.DefaultChunkOptions())
|
||||||
|
chunks, err := chunker.Chunk(context.Background(), testFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chunk() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should have 1 struct + 2 methods = 3 chunks
|
||||||
|
if len(chunks) != 3 {
|
||||||
|
t.Errorf("Expected 3 chunks (User struct, GetName, SetName), got %d", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the struct and methods
|
||||||
|
var structChunk, getNameChunk, setNameChunk *chunking.Chunk
|
||||||
|
for i := range chunks {
|
||||||
|
switch chunks[i].Name {
|
||||||
|
case "User":
|
||||||
|
structChunk = &chunks[i]
|
||||||
|
case "GetName":
|
||||||
|
getNameChunk = &chunks[i]
|
||||||
|
case "SetName":
|
||||||
|
setNameChunk = &chunks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify struct
|
||||||
|
if structChunk == nil {
|
||||||
|
t.Fatal("User struct not found")
|
||||||
|
}
|
||||||
|
if structChunk.Type != chunking.ChunkTypeClass {
|
||||||
|
t.Errorf("Expected User to be ChunkTypeClass, got %s", structChunk.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify methods
|
||||||
|
if getNameChunk == nil {
|
||||||
|
t.Fatal("GetName method not found")
|
||||||
|
}
|
||||||
|
if getNameChunk.Type != chunking.ChunkTypeMethod {
|
||||||
|
t.Errorf("Expected GetName to be ChunkTypeMethod, got %s", getNameChunk.Type)
|
||||||
|
}
|
||||||
|
if getNameChunk.ParentName != "User" {
|
||||||
|
t.Errorf("Expected GetName parent to be 'User', got '%s'", getNameChunk.ParentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if setNameChunk == nil {
|
||||||
|
t.Fatal("SetName method not found")
|
||||||
|
}
|
||||||
|
if setNameChunk.Type != chunking.ChunkTypeMethod {
|
||||||
|
t.Errorf("Expected SetName to be ChunkTypeMethod, got %s", setNameChunk.Type)
|
||||||
|
}
|
||||||
|
if setNameChunk.ParentName != "User" {
|
||||||
|
t.Errorf("Expected SetName parent to be 'User', got '%s'", setNameChunk.ParentName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoChunker_DocComments(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
testFile := filepath.Join(tmpDir, "test.go")
|
||||||
|
|
||||||
|
testCode := `package main
|
||||||
|
|
||||||
|
// Calculate performs a calculation.
|
||||||
|
// It takes two integers and returns their sum.
|
||||||
|
func Calculate(a, b int) int {
|
||||||
|
return a + b
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
if err := os.WriteFile(testFile, []byte(testCode), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create test file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunker := NewChunker(chunking.DefaultChunkOptions())
|
||||||
|
chunks, err := chunker.Chunk(context.Background(), testFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chunk() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chunks) != 1 {
|
||||||
|
t.Fatalf("Expected 1 chunk, got %d", len(chunks))
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := chunks[0]
|
||||||
|
if chunk.DocComment == "" {
|
||||||
|
t.Error("Expected doc comment to be present")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Doc comment should contain the comment text
|
||||||
|
expectedComment := "Calculate performs a calculation.\nIt takes two integers and returns their sum."
|
||||||
|
if chunk.DocComment != expectedComment {
|
||||||
|
t.Errorf("Expected doc comment '%s', got '%s'", expectedComment, chunk.DocComment)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package chunking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Manager dispatches files to appropriate language-specific chunkers.
|
||||||
|
type Manager struct {
|
||||||
|
chunkers map[string]Chunker // extension -> chunker
|
||||||
|
options ChunkOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new chunking manager with the given chunkers.
|
||||||
|
func NewManager(chunkers []Chunker, options ChunkOptions) *Manager {
|
||||||
|
m := &Manager{
|
||||||
|
chunkers: make(map[string]Chunker),
|
||||||
|
options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register chunkers by their supported extensions
|
||||||
|
for _, chunker := range chunkers {
|
||||||
|
for _, ext := range chunker.SupportedExtensions() {
|
||||||
|
m.chunkers[ext] = chunker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkFile chunks a single file using the appropriate language chunker.
|
||||||
|
// Returns an error if no chunker is found for the file extension.
|
||||||
|
func (m *Manager) ChunkFile(ctx context.Context, filePath string) ([]Chunk, error) {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
chunker, ok := m.chunkers[ext]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("no chunker for extension %s", ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks, err := chunker.Chunk(ctx, filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("chunk %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply options-based filtering
|
||||||
|
filtered := make([]Chunk, 0, len(chunks))
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
// Filter by minimum lines
|
||||||
|
if m.options.MinLines > 0 {
|
||||||
|
lineCount := chunk.EndLine - chunk.StartLine + 1
|
||||||
|
if lineCount < m.options.MinLines {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by maximum chunk size
|
||||||
|
if m.options.MaxChunkSize > 0 && len(chunk.Content) > m.options.MaxChunkSize {
|
||||||
|
// TODO: Consider splitting large chunks intelligently
|
||||||
|
// For now, skip chunks that are too large
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered = append(filtered, chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkFiles chunks multiple files in parallel.
|
||||||
|
// Returns a map of file path to chunks, and any errors encountered.
|
||||||
|
// Errors for individual files do not stop processing of other files.
|
||||||
|
func (m *Manager) ChunkFiles(ctx context.Context, filePaths []string) (map[string][]Chunk, []error) {
|
||||||
|
results := make(map[string][]Chunk)
|
||||||
|
var errors []error
|
||||||
|
|
||||||
|
for _, filePath := range filePaths {
|
||||||
|
chunks, err := m.ChunkFile(ctx, filePath)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Errorf("%s: %w", filePath, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(chunks) > 0 {
|
||||||
|
results[filePath] = chunks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportsFile checks if the manager can chunk the given file based on extension.
|
||||||
|
func (m *Manager) SupportsFile(filePath string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
|
_, ok := m.chunkers[ext]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedExtensions returns all file extensions supported by registered chunkers.
|
||||||
|
func (m *Manager) SupportedExtensions() []string {
|
||||||
|
exts := make([]string, 0, len(m.chunkers))
|
||||||
|
for ext := range m.chunkers {
|
||||||
|
exts = append(exts, ext)
|
||||||
|
}
|
||||||
|
return exts
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package chunking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockChunker is a test chunker that returns dummy chunks
|
||||||
|
type mockChunker struct{}
|
||||||
|
|
||||||
|
func (m *mockChunker) Chunk(ctx context.Context, filePath string) ([]Chunk, error) {
|
||||||
|
// Just return an empty chunk for testing
|
||||||
|
return []Chunk{
|
||||||
|
{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: LanguageGo,
|
||||||
|
Type: ChunkTypeFunction,
|
||||||
|
Name: "TestFunc",
|
||||||
|
StartLine: 1,
|
||||||
|
EndLine: 1,
|
||||||
|
Content: "test",
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChunker) Language() Language {
|
||||||
|
return LanguageGo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChunker) SupportedExtensions() []string {
|
||||||
|
return []string{".go", ".py", ".ts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_ChunkMultipleFiles(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create a Go file
|
||||||
|
goFile := filepath.Join(tmpDir, "test.go")
|
||||||
|
goCode := `package main
|
||||||
|
|
||||||
|
func Hello() string {
|
||||||
|
return "hello"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(goFile, []byte(goCode), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create Go file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a Python file
|
||||||
|
pyFile := filepath.Join(tmpDir, "test.py")
|
||||||
|
pyCode := `def greet(name):
|
||||||
|
return f"Hello, {name}!"
|
||||||
|
|
||||||
|
class User:
|
||||||
|
def __init__(self, name):
|
||||||
|
self.name = name
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(pyFile, []byte(pyCode), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create Python file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a TypeScript file
|
||||||
|
tsFile := filepath.Join(tmpDir, "test.ts")
|
||||||
|
tsCode := `function add(a: number, b: number): number {
|
||||||
|
return a + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
class Calculator {
|
||||||
|
multiply(a: number, b: number): number {
|
||||||
|
return a * b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(tsFile, []byte(tsCode), 0600); err != nil {
|
||||||
|
t.Fatalf("Failed to create TypeScript file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create manager
|
||||||
|
manager := NewManager([]Chunker{&mockChunker{}}, DefaultChunkOptions())
|
||||||
|
|
||||||
|
// Test SupportsFile
|
||||||
|
if !manager.SupportsFile(goFile) {
|
||||||
|
t.Error("Manager should support .go files")
|
||||||
|
}
|
||||||
|
if !manager.SupportsFile(pyFile) {
|
||||||
|
t.Error("Manager should support .py files")
|
||||||
|
}
|
||||||
|
if !manager.SupportsFile(tsFile) {
|
||||||
|
t.Error("Manager should support .ts files")
|
||||||
|
}
|
||||||
|
|
||||||
|
unsupportedFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
if manager.SupportsFile(unsupportedFile) {
|
||||||
|
t.Error("Manager should not support .txt files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test ChunkFiles
|
||||||
|
results, errs := manager.ChunkFiles(context.Background(), []string{goFile, pyFile, tsFile})
|
||||||
|
if len(errs) > 0 {
|
||||||
|
t.Errorf("ChunkFiles returned errors: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) != 3 {
|
||||||
|
t.Errorf("Expected results for 3 files, got %d", len(results))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify each file has chunks
|
||||||
|
for _, file := range []string{goFile, pyFile, tsFile} {
|
||||||
|
if chunks, ok := results[file]; !ok || len(chunks) == 0 {
|
||||||
|
t.Errorf("No chunks found for file %s", file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// mockChunkerWithExts is a test chunker with configurable extensions
|
||||||
|
type mockChunkerWithExts struct {
|
||||||
|
exts []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChunkerWithExts) Chunk(ctx context.Context, filePath string) ([]Chunk, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChunkerWithExts) Language() Language {
|
||||||
|
return LanguageGo
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockChunkerWithExts) SupportedExtensions() []string {
|
||||||
|
return m.exts
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager_SupportedExtensions(t *testing.T) {
|
||||||
|
|
||||||
|
// Create manager with mock chunkers
|
||||||
|
manager := NewManager([]Chunker{
|
||||||
|
&mockChunkerWithExts{exts: []string{".go"}},
|
||||||
|
&mockChunkerWithExts{exts: []string{".py", ".pyw"}},
|
||||||
|
}, DefaultChunkOptions())
|
||||||
|
|
||||||
|
exts := manager.SupportedExtensions()
|
||||||
|
expectedExts := map[string]bool{
|
||||||
|
".go": false,
|
||||||
|
".py": false,
|
||||||
|
".pyw": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ext := range exts {
|
||||||
|
if _, ok := expectedExts[ext]; ok {
|
||||||
|
expectedExts[ext] = true
|
||||||
|
} else {
|
||||||
|
t.Errorf("Unexpected extension: %s", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for ext, found := range expectedExts {
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected extension %s not found", ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
// Package python provides AST-aware chunking for Python source files using tree-sitter.
|
||||||
|
package python
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sitter "github.com/smacker/go-tree-sitter"
|
||||||
|
"github.com/smacker/go-tree-sitter/python"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunker implements AST-aware chunking for Python files.
|
||||||
|
type Chunker struct {
|
||||||
|
parser *sitter.Parser
|
||||||
|
options chunking.ChunkOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChunker creates a new Python chunker.
|
||||||
|
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
||||||
|
parser := sitter.NewParser()
|
||||||
|
parser.SetLanguage(python.GetLanguage())
|
||||||
|
|
||||||
|
return &Chunker{
|
||||||
|
options: options,
|
||||||
|
parser: parser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language returns the language this chunker supports.
|
||||||
|
func (c *Chunker) Language() chunking.Language {
|
||||||
|
return chunking.LanguagePython
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedExtensions returns the file extensions this chunker handles.
|
||||||
|
func (c *Chunker) SupportedExtensions() []string {
|
||||||
|
return []string{".py"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk parses a Python source file and returns semantic code chunks.
|
||||||
|
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
||||||
|
// Read file content
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the Python file
|
||||||
|
tree, err := c.parser.ParseCtx(ctx, nil, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse Python file: %w", err)
|
||||||
|
}
|
||||||
|
defer tree.Close()
|
||||||
|
|
||||||
|
sourceLines := strings.Split(string(content), "\n")
|
||||||
|
chunks := make([]chunking.Chunk, 0)
|
||||||
|
|
||||||
|
// Walk the AST and extract chunks
|
||||||
|
c.walkNode(tree.RootNode(), content, sourceLines, filePath, "", &chunks)
|
||||||
|
|
||||||
|
return chunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkNode recursively walks the tree-sitter AST and extracts chunks.
|
||||||
|
func (c *Chunker) walkNode(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string, chunks *[]chunking.Chunk) {
|
||||||
|
nodeType := node.Type()
|
||||||
|
|
||||||
|
switch nodeType {
|
||||||
|
case "function_definition":
|
||||||
|
chunk := c.extractFunction(node, source, sourceLines, filePath, parentName)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "class_definition":
|
||||||
|
chunk := c.extractClass(node, source, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
|
||||||
|
// Walk class body to find methods
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "block" {
|
||||||
|
c.walkNode(child, source, sourceLines, filePath, chunk.Name, chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return // Don't walk children again
|
||||||
|
|
||||||
|
case "block":
|
||||||
|
// Walk statements in block
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk all children
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunction extracts a function definition chunk.
|
||||||
|
func (c *Chunker) extractFunction(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||||
|
// Find function name
|
||||||
|
var nameNode *sitter.Node
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "identifier" {
|
||||||
|
nameNode = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nameNode == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := nameNode.Content(source)
|
||||||
|
|
||||||
|
// Skip private functions if configured
|
||||||
|
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") && !strings.HasPrefix(name, "__") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguagePython,
|
||||||
|
Name: name,
|
||||||
|
ParentName: parentName,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is a method or function
|
||||||
|
if parentName != "" {
|
||||||
|
chunk.Type = chunking.ChunkTypeMethod
|
||||||
|
} else {
|
||||||
|
chunk.Type = chunking.ChunkTypeFunction
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract signature (def line)
|
||||||
|
chunk.Signature = c.extractFunctionSignature(node, source, sourceLines)
|
||||||
|
|
||||||
|
// Extract docstring as doc comment
|
||||||
|
if c.options.IncludeDocComments {
|
||||||
|
chunk.DocComment = c.extractDocstring(node, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClass extracts a class definition chunk.
|
||||||
|
func (c *Chunker) extractClass(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
// Find class name
|
||||||
|
var nameNode *sitter.Node
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "identifier" {
|
||||||
|
nameNode = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if nameNode == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
name := nameNode.Content(source)
|
||||||
|
|
||||||
|
// Skip private classes if configured
|
||||||
|
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") && !strings.HasPrefix(name, "__") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguagePython,
|
||||||
|
Type: chunking.ChunkTypeClass,
|
||||||
|
Name: name,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract class signature (class line)
|
||||||
|
chunk.Signature = c.extractClassSignature(node, source, sourceLines)
|
||||||
|
|
||||||
|
// Extract docstring as doc comment
|
||||||
|
if c.options.IncludeDocComments {
|
||||||
|
chunk.DocComment = c.extractDocstring(node, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunctionSignature extracts the function definition line.
|
||||||
|
func (c *Chunker) extractFunctionSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
|
||||||
|
// Find the colon that ends the signature
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == ":" {
|
||||||
|
endLine := int(child.EndPoint().Row) + 1
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: just return first line
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClassSignature extracts the class definition line.
|
||||||
|
func (c *Chunker) extractClassSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
|
||||||
|
// Find the colon that ends the signature
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == ":" {
|
||||||
|
endLine := int(child.EndPoint().Row) + 1
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: just return first line
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDocstring extracts the docstring from a function or class.
|
||||||
|
func (c *Chunker) extractDocstring(node *sitter.Node, source []byte) string {
|
||||||
|
// Find the block
|
||||||
|
var blockNode *sitter.Node
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "block" {
|
||||||
|
blockNode = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if blockNode == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if first statement in block is a string (docstring)
|
||||||
|
for i := 0; i < int(blockNode.ChildCount()); i++ {
|
||||||
|
child := blockNode.Child(i)
|
||||||
|
if child.Type() == "expression_statement" {
|
||||||
|
// Check if it contains a string
|
||||||
|
for j := 0; j < int(child.ChildCount()); j++ {
|
||||||
|
grandchild := child.Child(j)
|
||||||
|
if grandchild.Type() == "string" {
|
||||||
|
docstring := grandchild.Content(source)
|
||||||
|
// Remove quotes
|
||||||
|
docstring = strings.Trim(docstring, `"'`)
|
||||||
|
return strings.TrimSpace(docstring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
||||||
|
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
||||||
|
if start < 1 || end < start || start > len(lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
startIdx := start - 1
|
||||||
|
endIdx := end
|
||||||
|
if endIdx > len(lines) {
|
||||||
|
endIdx = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines[startIdx:endIdx], "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
// Package chunking provides AST-aware code chunking for semantic code search.
|
||||||
|
// Chunks code files into logical units (functions, classes, methods) that preserve
|
||||||
|
// semantic boundaries for better vector embedding and retrieval.
|
||||||
|
package chunking
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChunkType represents the type of code chunk.
|
||||||
|
type ChunkType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ChunkTypeFunction represents a standalone function.
|
||||||
|
ChunkTypeFunction ChunkType = "function"
|
||||||
|
// ChunkTypeMethod represents a method on a class/struct/type.
|
||||||
|
ChunkTypeMethod ChunkType = "method"
|
||||||
|
// ChunkTypeClass represents a class or struct definition.
|
||||||
|
ChunkTypeClass ChunkType = "class"
|
||||||
|
// ChunkTypeInterface represents an interface definition.
|
||||||
|
ChunkTypeInterface ChunkType = "interface"
|
||||||
|
// ChunkTypeType represents a type alias or type definition.
|
||||||
|
ChunkTypeType ChunkType = "type"
|
||||||
|
// ChunkTypeConst represents constant declarations.
|
||||||
|
ChunkTypeConst ChunkType = "const"
|
||||||
|
// ChunkTypeVar represents variable declarations.
|
||||||
|
ChunkTypeVar ChunkType = "var"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Language represents a programming language.
|
||||||
|
type Language string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// LanguageGo represents the Go programming language.
|
||||||
|
LanguageGo Language = "go"
|
||||||
|
// LanguagePython represents the Python programming language.
|
||||||
|
LanguagePython Language = "python"
|
||||||
|
// LanguageTypeScript represents the TypeScript programming language.
|
||||||
|
LanguageTypeScript Language = "typescript"
|
||||||
|
// LanguageJavaScript represents the JavaScript programming language.
|
||||||
|
LanguageJavaScript Language = "javascript"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunk represents a semantic code chunk with AST-derived boundaries.
|
||||||
|
type Chunk struct {
|
||||||
|
Metadata map[string]interface{}
|
||||||
|
FilePath string
|
||||||
|
Language Language
|
||||||
|
Type ChunkType
|
||||||
|
Name string
|
||||||
|
ParentName string
|
||||||
|
Content string
|
||||||
|
Signature string
|
||||||
|
DocComment string
|
||||||
|
StartLine int
|
||||||
|
EndLine int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifier returns a human-readable identifier for this chunk.
|
||||||
|
// Format: "ParentName.Name" for methods, "Name" for top-level.
|
||||||
|
func (c *Chunk) Identifier() string {
|
||||||
|
if c.ParentName != "" {
|
||||||
|
return fmt.Sprintf("%s.%s", c.ParentName, c.Name)
|
||||||
|
}
|
||||||
|
return c.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// LineRange returns a human-readable line range.
|
||||||
|
// Format: "L123-L456"
|
||||||
|
func (c *Chunk) LineRange() string {
|
||||||
|
return fmt.Sprintf("L%d-L%d", c.StartLine, c.EndLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchableContent returns content optimized for semantic search.
|
||||||
|
// Combines signature, doc comment, and content in a structured format.
|
||||||
|
func (c *Chunk) SearchableContent() string {
|
||||||
|
var parts []string
|
||||||
|
|
||||||
|
// Include signature for functions/methods
|
||||||
|
if c.Signature != "" {
|
||||||
|
parts = append(parts, c.Signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include doc comment
|
||||||
|
if c.DocComment != "" {
|
||||||
|
parts = append(parts, c.DocComment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include actual content
|
||||||
|
if c.Content != "" {
|
||||||
|
parts = append(parts, c.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(parts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunker is the interface for language-specific code chunkers.
|
||||||
|
type Chunker interface {
|
||||||
|
// Chunk parses a source file and returns semantic code chunks.
|
||||||
|
// Returns an error if the file cannot be parsed or read.
|
||||||
|
Chunk(ctx context.Context, filePath string) ([]Chunk, error)
|
||||||
|
|
||||||
|
// Language returns the language this chunker supports.
|
||||||
|
Language() Language
|
||||||
|
|
||||||
|
// SupportedExtensions returns file extensions this chunker handles.
|
||||||
|
// Example: []string{".go"} for Go chunker
|
||||||
|
SupportedExtensions() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChunkOptions provides options for chunking behavior.
|
||||||
|
type ChunkOptions struct {
|
||||||
|
// MaxChunkSize is the maximum size of a chunk in bytes.
|
||||||
|
// Chunks larger than this will be split (respecting boundaries where possible).
|
||||||
|
// 0 means no limit.
|
||||||
|
MaxChunkSize int
|
||||||
|
|
||||||
|
// IncludeDocComments controls whether to include documentation comments.
|
||||||
|
IncludeDocComments bool
|
||||||
|
|
||||||
|
// IncludePrivate controls whether to include private/unexported symbols.
|
||||||
|
IncludePrivate bool
|
||||||
|
|
||||||
|
// MinLines is the minimum number of lines for a chunk to be included.
|
||||||
|
// Chunks smaller than this will be skipped.
|
||||||
|
// 0 means no minimum.
|
||||||
|
MinLines int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultChunkOptions returns sensible default options.
|
||||||
|
func DefaultChunkOptions() ChunkOptions {
|
||||||
|
return ChunkOptions{
|
||||||
|
MaxChunkSize: 8192, // ~8KB per chunk (well under token limit)
|
||||||
|
IncludeDocComments: true,
|
||||||
|
IncludePrivate: true, // Include all symbols for comprehensive search
|
||||||
|
MinLines: 0, // No minimum - include even single-line functions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
// Package typescript provides AST-aware chunking for TypeScript and JavaScript source files using tree-sitter.
|
||||||
|
package typescript
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
sitter "github.com/smacker/go-tree-sitter"
|
||||||
|
"github.com/smacker/go-tree-sitter/typescript/typescript"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chunker implements AST-aware chunking for TypeScript/JavaScript files.
|
||||||
|
type Chunker struct {
|
||||||
|
parser *sitter.Parser
|
||||||
|
options chunking.ChunkOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChunker creates a new TypeScript chunker.
|
||||||
|
func NewChunker(options chunking.ChunkOptions) *Chunker {
|
||||||
|
parser := sitter.NewParser()
|
||||||
|
parser.SetLanguage(typescript.GetLanguage())
|
||||||
|
|
||||||
|
return &Chunker{
|
||||||
|
options: options,
|
||||||
|
parser: parser,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language returns the language this chunker supports.
|
||||||
|
func (c *Chunker) Language() chunking.Language {
|
||||||
|
return chunking.LanguageTypeScript
|
||||||
|
}
|
||||||
|
|
||||||
|
// SupportedExtensions returns the file extensions this chunker handles.
|
||||||
|
func (c *Chunker) SupportedExtensions() []string {
|
||||||
|
return []string{".ts", ".tsx", ".js", ".jsx"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk parses a TypeScript/JavaScript source file and returns semantic code chunks.
|
||||||
|
func (c *Chunker) Chunk(ctx context.Context, filePath string) ([]chunking.Chunk, error) {
|
||||||
|
// Read file content
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the file
|
||||||
|
tree, err := c.parser.ParseCtx(ctx, nil, content)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse TypeScript file: %w", err)
|
||||||
|
}
|
||||||
|
defer tree.Close()
|
||||||
|
|
||||||
|
sourceLines := strings.Split(string(content), "\n")
|
||||||
|
chunks := make([]chunking.Chunk, 0)
|
||||||
|
|
||||||
|
// Walk the AST and extract chunks
|
||||||
|
c.walkNode(tree.RootNode(), content, sourceLines, filePath, "", &chunks)
|
||||||
|
|
||||||
|
return chunks, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkNode recursively walks the tree-sitter AST and extracts chunks.
|
||||||
|
func (c *Chunker) walkNode(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string, chunks *[]chunking.Chunk) {
|
||||||
|
nodeType := node.Type()
|
||||||
|
|
||||||
|
switch nodeType {
|
||||||
|
case "function_declaration":
|
||||||
|
chunk := c.extractFunction(node, source, sourceLines, filePath, parentName)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "method_definition":
|
||||||
|
chunk := c.extractMethod(node, source, sourceLines, filePath, parentName)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "arrow_function", "function_expression":
|
||||||
|
// Handle arrow functions and function expressions assigned to variables
|
||||||
|
chunk := c.extractFunctionExpression(node, source, sourceLines, filePath, parentName)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "class_declaration":
|
||||||
|
chunk := c.extractClass(node, source, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
|
||||||
|
// Walk class body to find methods
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "class_body" {
|
||||||
|
c.walkNode(child, source, sourceLines, filePath, chunk.Name, chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return // Don't walk children again
|
||||||
|
|
||||||
|
case "interface_declaration":
|
||||||
|
chunk := c.extractInterface(node, source, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
case "type_alias_declaration":
|
||||||
|
chunk := c.extractTypeAlias(node, source, sourceLines, filePath)
|
||||||
|
if chunk != nil {
|
||||||
|
*chunks = append(*chunks, *chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk all children
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
c.walkNode(node.Child(i), source, sourceLines, filePath, parentName, chunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunction extracts a function declaration.
|
||||||
|
func (c *Chunker) extractFunction(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||||
|
name := c.findChildContent(node, "identifier", source)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageTypeScript,
|
||||||
|
Type: chunking.ChunkTypeFunction,
|
||||||
|
Name: name,
|
||||||
|
ParentName: parentName,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
Signature: c.extractFunctionSignature(node, source, sourceLines),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSDoc comment
|
||||||
|
if c.options.IncludeDocComments {
|
||||||
|
chunk.DocComment = c.extractComment(node, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMethod extracts a method definition from a class.
|
||||||
|
func (c *Chunker) extractMethod(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||||
|
name := c.findChildContent(node, "property_identifier", source)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip private methods if configured
|
||||||
|
if !c.options.IncludePrivate && strings.HasPrefix(name, "_") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageTypeScript,
|
||||||
|
Type: chunking.ChunkTypeMethod,
|
||||||
|
Name: name,
|
||||||
|
ParentName: parentName,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
Signature: c.extractMethodSignature(node, source, sourceLines),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSDoc comment
|
||||||
|
if c.options.IncludeDocComments {
|
||||||
|
chunk.DocComment = c.extractComment(node, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunctionExpression extracts arrow functions and function expressions.
|
||||||
|
func (c *Chunker) extractFunctionExpression(node *sitter.Node, source []byte, sourceLines []string, filePath string, parentName string) *chunking.Chunk {
|
||||||
|
// Try to find the variable name from parent
|
||||||
|
parent := node.Parent()
|
||||||
|
if parent == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
if parent.Type() == "variable_declarator" {
|
||||||
|
name = c.findChildContent(parent, "identifier", source)
|
||||||
|
} else if parent.Type() == "assignment_expression" {
|
||||||
|
// Handle const foo = () => {}
|
||||||
|
for i := 0; i < int(parent.ChildCount()); i++ {
|
||||||
|
child := parent.Child(i)
|
||||||
|
if child.Type() == "identifier" || child.Type() == "member_expression" {
|
||||||
|
name = child.Content(source)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
return nil // Anonymous function, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageTypeScript,
|
||||||
|
Type: chunking.ChunkTypeFunction,
|
||||||
|
Name: name,
|
||||||
|
ParentName: parentName,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClass extracts a class declaration.
|
||||||
|
func (c *Chunker) extractClass(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
name := c.findChildContent(node, "type_identifier", source)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageTypeScript,
|
||||||
|
Type: chunking.ChunkTypeClass,
|
||||||
|
Name: name,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
Signature: c.extractClassSignature(node, source, sourceLines),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSDoc comment
|
||||||
|
if c.options.IncludeDocComments {
|
||||||
|
chunk.DocComment = c.extractComment(node, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractInterface extracts an interface declaration.
|
||||||
|
func (c *Chunker) extractInterface(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
name := c.findChildContent(node, "type_identifier", source)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageTypeScript,
|
||||||
|
Type: chunking.ChunkTypeInterface,
|
||||||
|
Name: name,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract JSDoc comment
|
||||||
|
if c.options.IncludeDocComments {
|
||||||
|
chunk.DocComment = c.extractComment(node, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTypeAlias extracts a type alias declaration.
|
||||||
|
func (c *Chunker) extractTypeAlias(node *sitter.Node, source []byte, sourceLines []string, filePath string) *chunking.Chunk {
|
||||||
|
name := c.findChildContent(node, "type_identifier", source)
|
||||||
|
if name == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
endLine := int(node.EndPoint().Row) + 1
|
||||||
|
|
||||||
|
chunk := &chunking.Chunk{
|
||||||
|
FilePath: filePath,
|
||||||
|
Language: chunking.LanguageTypeScript,
|
||||||
|
Type: chunking.ChunkTypeType,
|
||||||
|
Name: name,
|
||||||
|
StartLine: startLine,
|
||||||
|
EndLine: endLine,
|
||||||
|
Content: c.extractLines(sourceLines, startLine, endLine),
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunk
|
||||||
|
}
|
||||||
|
|
||||||
|
// findChildContent finds the first child of the given type and returns its content.
|
||||||
|
func (c *Chunker) findChildContent(node *sitter.Node, childType string, source []byte) string {
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == childType {
|
||||||
|
return child.Content(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractFunctionSignature extracts the function signature.
|
||||||
|
func (c *Chunker) extractFunctionSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
|
||||||
|
// Find the opening brace of the body
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "statement_block" {
|
||||||
|
endLine := int(child.StartPoint().Row) + 1
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: just return first line
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractMethodSignature extracts the method signature.
|
||||||
|
func (c *Chunker) extractMethodSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
|
||||||
|
// Find the opening brace of the body
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "statement_block" {
|
||||||
|
endLine := int(child.StartPoint().Row) + 1
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractClassSignature extracts the class declaration line.
|
||||||
|
func (c *Chunker) extractClassSignature(node *sitter.Node, source []byte, sourceLines []string) string {
|
||||||
|
startLine := int(node.StartPoint().Row) + 1
|
||||||
|
|
||||||
|
// Find the opening brace of the class body
|
||||||
|
for i := 0; i < int(node.ChildCount()); i++ {
|
||||||
|
child := node.Child(i)
|
||||||
|
if child.Type() == "class_body" {
|
||||||
|
endLine := int(child.StartPoint().Row) + 1
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, endLine-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(c.extractLines(sourceLines, startLine, startLine))
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractComment extracts JSDoc or other comments from a node.
|
||||||
|
func (c *Chunker) extractComment(node *sitter.Node, source []byte) string {
|
||||||
|
// Check previous sibling for comment
|
||||||
|
prevSibling := node.PrevSibling()
|
||||||
|
if prevSibling != nil && prevSibling.Type() == "comment" {
|
||||||
|
comment := prevSibling.Content(source)
|
||||||
|
// Remove comment markers
|
||||||
|
comment = strings.TrimPrefix(comment, "/**")
|
||||||
|
comment = strings.TrimPrefix(comment, "/*")
|
||||||
|
comment = strings.TrimSuffix(comment, "*/")
|
||||||
|
comment = strings.TrimPrefix(comment, "//")
|
||||||
|
return strings.TrimSpace(comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractLines extracts a range of lines from source (1-indexed, inclusive).
|
||||||
|
func (c *Chunker) extractLines(lines []string, start, end int) string {
|
||||||
|
if start < 1 || end < start || start > len(lines) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
startIdx := start - 1
|
||||||
|
endIdx := end
|
||||||
|
if endIdx > len(lines) {
|
||||||
|
endIdx = len(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines[startIdx:endIdx], "\n")
|
||||||
|
}
|
||||||
+19
-30
@@ -37,40 +37,29 @@ var CriticalConcepts = []string{
|
|||||||
|
|
||||||
// Config holds the application configuration.
|
// Config holds the application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Worker settings
|
ContextFullField string `json:"context_full_field"`
|
||||||
WorkerPort int `json:"worker_port"`
|
DBPath string `json:"db_path"`
|
||||||
|
Model string `json:"model"`
|
||||||
// Database settings
|
ClaudeCodePath string `json:"claude_code_path"`
|
||||||
DBPath string `json:"db_path"`
|
EmbeddingModel string `json:"embedding_model"`
|
||||||
MaxConns int `json:"max_conns"`
|
ContextObsConcepts []string `json:"context_obs_concepts"`
|
||||||
|
ContextObsTypes []string `json:"context_obs_types"`
|
||||||
// SDK Agent settings
|
RerankingMinImprovement float64 `json:"reranking_min_improvement"`
|
||||||
Model string `json:"model"`
|
RerankingCandidates int `json:"reranking_candidates"`
|
||||||
ClaudeCodePath string `json:"claude_code_path"`
|
RerankingAlpha float64 `json:"reranking_alpha"`
|
||||||
|
WorkerPort int `json:"worker_port"`
|
||||||
// Embedding settings
|
ContextMaxPromptResults int `json:"context_max_prompt_results"`
|
||||||
EmbeddingModel string `json:"embedding_model"` // e.g., "bge-v1.5"
|
|
||||||
|
|
||||||
// Reranking settings (cross-encoder)
|
|
||||||
RerankingEnabled bool `json:"reranking_enabled"` // Enable cross-encoder reranking
|
|
||||||
RerankingCandidates int `json:"reranking_candidates"` // Number of candidates to retrieve before reranking (default 100)
|
|
||||||
RerankingResults int `json:"reranking_results"` // Number of results to return after reranking (default 10)
|
|
||||||
RerankingAlpha float64 `json:"reranking_alpha"` // Weight for combining scores: alpha*rerank + (1-alpha)*original (default 0.7)
|
|
||||||
RerankingMinImprovement float64 `json:"reranking_min_improvement"` // Minimum rank improvement to trigger reranking (default 0, always rerank)
|
|
||||||
RerankingPureMode bool `json:"reranking_pure_mode"` // Use pure cross-encoder scores without combining with bi-encoder (default false)
|
|
||||||
|
|
||||||
// Context injection settings
|
|
||||||
ContextObservations int `json:"context_observations"`
|
ContextObservations int `json:"context_observations"`
|
||||||
ContextFullCount int `json:"context_full_count"`
|
ContextFullCount int `json:"context_full_count"`
|
||||||
ContextSessionCount int `json:"context_session_count"`
|
ContextSessionCount int `json:"context_session_count"`
|
||||||
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"`
|
||||||
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
MaxConns int `json:"max_conns"`
|
||||||
ContextFullField string `json:"context_full_field"`
|
RerankingResults int `json:"reranking_results"`
|
||||||
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
ContextShowLastSummary bool `json:"context_show_last_summary"`
|
||||||
ContextObsTypes []string `json:"context_obs_types"`
|
RerankingEnabled bool `json:"reranking_enabled"`
|
||||||
ContextObsConcepts []string `json:"context_obs_concepts"`
|
ContextShowWorkTokens bool `json:"context_show_work_tokens"`
|
||||||
ContextRelevanceThreshold float64 `json:"context_relevance_threshold"` // 0.0-1.0, minimum similarity for inclusion
|
ContextShowReadTokens bool `json:"context_show_read_tokens"`
|
||||||
ContextMaxPromptResults int `json:"context_max_prompt_results"` // Max results per prompt (0 = threshold only)
|
RerankingPureMode bool `json:"reranking_pure_mode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -121,8 +121,8 @@ func (s *ConfigSuite) TestLoad_TableDriven() {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
settingsJSON string
|
settingsJSON string
|
||||||
expectedPort int
|
|
||||||
expectedModel string
|
expectedModel string
|
||||||
|
expectedPort int
|
||||||
expectedObsObs int
|
expectedObsObs int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -183,12 +183,12 @@ func (s *ConfigSuite) TestLoad_TableDriven() {
|
|||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
|
|
||||||
if tt.settingsJSON != "" {
|
if tt.settingsJSON != "" {
|
||||||
err := os.WriteFile(
|
writeErr := os.WriteFile(
|
||||||
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
|
filepath.Join(tempDir, ".claude-mnemonic", "settings.json"),
|
||||||
[]byte(tt.settingsJSON),
|
[]byte(tt.settingsJSON),
|
||||||
0600,
|
0600,
|
||||||
)
|
)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(writeErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := Load()
|
cfg, err := Load()
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ func (s *ConflictStore) CleanupSupersededObservations(ctx context.Context, proje
|
|||||||
var toDelete []int64
|
var toDelete []int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
if err := rows.Scan(&id); err != nil {
|
if scanErr := rows.Scan(&id); scanErr != nil {
|
||||||
return nil, err
|
return nil, scanErr
|
||||||
}
|
}
|
||||||
toDelete = append(toDelete, id)
|
toDelete = append(toDelete, id)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if rowsErr := rows.Err(); rowsErr != nil {
|
||||||
return nil, err
|
return nil, rowsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
@@ -197,8 +197,8 @@ func (s *ConflictStore) CleanupSupersededObservations(ctx context.Context, proje
|
|||||||
|
|
||||||
// Delete the conflict records first (due to foreign key constraints)
|
// Delete the conflict records first (due to foreign key constraints)
|
||||||
for _, obsID := range toDelete {
|
for _, obsID := range toDelete {
|
||||||
if err := s.DeleteConflictsByObservationID(ctx, obsID); err != nil {
|
if delErr := s.DeleteConflictsByObservationID(ctx, obsID); delErr != nil {
|
||||||
return nil, err
|
return nil, delErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ func TestNullInt(t *testing.T) {
|
|||||||
func TestRepeatPlaceholders(t *testing.T) {
|
func TestRepeatPlaceholders(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
n int
|
|
||||||
expected string
|
expected string
|
||||||
|
n int
|
||||||
}{
|
}{
|
||||||
{"zero", 0, ""},
|
{name: "zero", n: 0, expected: ""},
|
||||||
{"negative", -1, ""},
|
{name: "negative", n: -1, expected: ""},
|
||||||
{"one", 1, ", ?"},
|
{name: "one", n: 1, expected: ", ?"},
|
||||||
{"two", 2, ", ?, ?"},
|
{name: "two", n: 2, expected: ", ?, ?"},
|
||||||
{"three", 3, ", ?, ?, ?"},
|
{name: "three", n: 3, expected: ", ?, ?, ?"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
|
|
||||||
// Migration represents a database schema migration.
|
// Migration represents a database schema migration.
|
||||||
type Migration struct {
|
type Migration struct {
|
||||||
Version int
|
|
||||||
Name string
|
Name string
|
||||||
SQL string
|
SQL string
|
||||||
|
Version int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrations is the list of all database migrations in order.
|
// Migrations is the list of all database migrations in order.
|
||||||
@@ -539,11 +539,11 @@ func (m *MigrationManager) ApplyMigration(migration Migration) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("begin transaction: %w", err)
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
// Execute migration SQL
|
// Execute migration SQL
|
||||||
if _, err := tx.Exec(migration.SQL); err != nil {
|
if _, execErr := tx.Exec(migration.SQL); execErr != nil {
|
||||||
return fmt.Errorf("execute migration %d (%s): %w", migration.Version, migration.Name, err)
|
return fmt.Errorf("execute migration %d (%s): %w", migration.Version, migration.Name, execErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record migration
|
// Record migration
|
||||||
|
|||||||
@@ -585,13 +585,13 @@ func (s *ObservationStore) CleanupOldObservations(ctx context.Context, project s
|
|||||||
var toDelete []int64
|
var toDelete []int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
if err := rows.Scan(&id); err != nil {
|
if scanErr := rows.Scan(&id); scanErr != nil {
|
||||||
return nil, err
|
return nil, scanErr
|
||||||
}
|
}
|
||||||
toDelete = append(toDelete, id)
|
toDelete = append(toDelete, id)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if rowsErr := rows.Err(); rowsErr != nil {
|
||||||
return nil, err
|
return nil, rowsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
|
|||||||
@@ -65,10 +65,10 @@ func (s *ObservationStoreSuite) TestStoreObservation_TableDriven() {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
obs *models.ParsedObservation
|
||||||
name string
|
name string
|
||||||
sdkSessionID string
|
sdkSessionID string
|
||||||
project string
|
project string
|
||||||
obs *models.ParsedObservation
|
|
||||||
promptNum int
|
promptNum int
|
||||||
tokens int64
|
tokens int64
|
||||||
wantErr bool
|
wantErr bool
|
||||||
@@ -308,8 +308,8 @@ func (s *ObservationStoreSuite) TestGetObservationsByIDs() {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
queryIDs []int64
|
|
||||||
orderBy string
|
orderBy string
|
||||||
|
queryIDs []int64
|
||||||
limit int
|
limit int
|
||||||
wantCount int
|
wantCount int
|
||||||
}{
|
}{
|
||||||
|
|||||||
@@ -102,13 +102,13 @@ func (s *PromptStore) CleanupOldPrompts(ctx context.Context) ([]int64, error) {
|
|||||||
var toDelete []int64
|
var toDelete []int64
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var id int64
|
var id int64
|
||||||
if err := rows.Scan(&id); err != nil {
|
if scanErr := rows.Scan(&id); scanErr != nil {
|
||||||
return nil, err
|
return nil, scanErr
|
||||||
}
|
}
|
||||||
toDelete = append(toDelete, id)
|
toDelete = append(toDelete, id)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if rowsErr := rows.Err(); rowsErr != nil {
|
||||||
return nil, err
|
return nil, rowsErr
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(toDelete) == 0 {
|
if len(toDelete) == 0 {
|
||||||
|
|||||||
@@ -261,14 +261,14 @@ func TestPromptStore_SaveMultiplePrompts(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
claudeSessionID string
|
claudeSessionID string
|
||||||
promptNum int
|
|
||||||
text string
|
text string
|
||||||
|
promptNum int
|
||||||
matches int
|
matches int
|
||||||
}{
|
}{
|
||||||
{"claude-1", 1, "First prompt", 5},
|
{claudeSessionID: "claude-1", promptNum: 1, text: "First prompt", matches: 5},
|
||||||
{"claude-1", 2, "Second prompt", 3},
|
{claudeSessionID: "claude-1", promptNum: 2, text: "Second prompt", matches: 3},
|
||||||
{"claude-2", 1, "Third prompt", 0},
|
{claudeSessionID: "claude-2", promptNum: 1, text: "Third prompt", matches: 0},
|
||||||
{"claude-1", 3, "Fourth prompt", 10},
|
{claudeSessionID: "claude-1", promptNum: 3, text: "Fourth prompt", matches: 10},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (s *ObservationStore) UpdateImportanceScores(ctx context.Context, scores ma
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
@@ -171,7 +171,7 @@ func (s *ObservationStore) UpdateConceptWeights(ctx context.Context, weights map
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
stmt, err := tx.PrepareContext(ctx, `
|
stmt, err := tx.PrepareContext(ctx, `
|
||||||
INSERT INTO concept_weights (concept, weight, updated_at)
|
INSERT INTO concept_weights (concept, weight, updated_at)
|
||||||
|
|||||||
@@ -211,8 +211,8 @@ func (s *SessionStoreSuite) TestFindAnySDKSession_Scenarios() {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
claudeSessionID string
|
claudeSessionID string
|
||||||
wantFound bool
|
|
||||||
wantProject string
|
wantProject string
|
||||||
|
wantFound bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "find existing session 1",
|
name: "find existing session 1",
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ func (s *Store) GetStmt(query string) (*sql.Stmt, error) {
|
|||||||
defer s.stmtMu.Unlock()
|
defer s.stmtMu.Unlock()
|
||||||
|
|
||||||
// Double-check after acquiring write lock
|
// Double-check after acquiring write lock
|
||||||
if stmt, ok := s.stmtCache[query]; ok {
|
if cachedStmt, ok := s.stmtCache[query]; ok {
|
||||||
return stmt, nil
|
return cachedStmt, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
stmt, err := s.db.Prepare(query)
|
stmt, err := s.db.Prepare(query)
|
||||||
|
|||||||
@@ -130,13 +130,13 @@ func (s *StoreSuite) TestQueryContext() {
|
|||||||
seedSession(s.T(), s.db, "claude-1", "sdk-1", "project-a")
|
seedSession(s.T(), s.db, "claude-1", "sdk-1", "project-a")
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
setupFunc func()
|
||||||
|
assertFunc func(rows *sql.Rows)
|
||||||
name string
|
name string
|
||||||
query string
|
query string
|
||||||
args []interface{}
|
args []interface{}
|
||||||
wantErr bool
|
|
||||||
wantRows int
|
wantRows int
|
||||||
setupFunc func()
|
wantErr bool
|
||||||
assertFunc func(rows *sql.Rows)
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "query existing session",
|
name: "query existing session",
|
||||||
@@ -366,8 +366,8 @@ func (s *HelpersSuite) TestNullInt() {
|
|||||||
func (s *HelpersSuite) TestRepeatPlaceholders() {
|
func (s *HelpersSuite) TestRepeatPlaceholders() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input int
|
|
||||||
expected string
|
expected string
|
||||||
|
input int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "zero",
|
name: "zero",
|
||||||
@@ -438,10 +438,10 @@ func TestBuildGetByIDsQuery(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
baseQuery string
|
baseQuery string
|
||||||
ids []int64
|
|
||||||
orderBy string
|
orderBy string
|
||||||
limit int
|
|
||||||
wantQuery string
|
wantQuery string
|
||||||
|
ids []int64
|
||||||
|
limit int
|
||||||
wantArgs int
|
wantArgs int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -483,10 +483,10 @@ func TestEnsureSessionExists(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
setup func()
|
||||||
name string
|
name string
|
||||||
sdkSessionID string
|
sdkSessionID string
|
||||||
project string
|
project string
|
||||||
setup func()
|
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -21,15 +21,10 @@ const (
|
|||||||
// ONNXConfig describes ONNX-specific model configuration.
|
// ONNXConfig describes ONNX-specific model configuration.
|
||||||
// This allows different models to specify their tensor names and pooling needs.
|
// This allows different models to specify their tensor names and pooling needs.
|
||||||
type ONNXConfig struct {
|
type ONNXConfig struct {
|
||||||
// InputNames are the ONNX input tensor names in order.
|
Pooling PoolingStrategy
|
||||||
InputNames []string
|
InputNames []string
|
||||||
// OutputNames are the ONNX output tensor names.
|
|
||||||
OutputNames []string
|
OutputNames []string
|
||||||
// Pooling specifies how to convert token embeddings to sentence embeddings.
|
HiddenSize int
|
||||||
// If PoolingNone, the model outputs sentence embeddings directly.
|
|
||||||
Pooling PoolingStrategy
|
|
||||||
// HiddenSize is the embedding dimension (used for pooling calculations).
|
|
||||||
HiddenSize int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmbeddingModel represents a text embedding model.
|
// EmbeddingModel represents a text embedding model.
|
||||||
@@ -62,11 +57,11 @@ type ONNXConfigurer interface {
|
|||||||
|
|
||||||
// ModelMetadata describes an embedding model for UI/config.
|
// ModelMetadata describes an embedding model for UI/config.
|
||||||
type ModelMetadata struct {
|
type ModelMetadata struct {
|
||||||
Name string `json:"name"` // Human-readable name
|
Name string `json:"name"`
|
||||||
Version string `json:"version"` // Short ID for DB storage
|
Version string `json:"version"`
|
||||||
Dimensions int `json:"dimensions"` // Vector size
|
Description string `json:"description"`
|
||||||
Description string `json:"description"` // Brief description
|
Dimensions int `json:"dimensions"`
|
||||||
Default bool `json:"default"` // Is this the default model?
|
Default bool `json:"default"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelFactory creates a new instance of an embedding model.
|
// ModelFactory creates a new instance of an embedding model.
|
||||||
@@ -74,10 +69,10 @@ type ModelFactory func() (EmbeddingModel, error)
|
|||||||
|
|
||||||
// ModelRegistry provides model lookup by version.
|
// ModelRegistry provides model lookup by version.
|
||||||
type ModelRegistry struct {
|
type ModelRegistry struct {
|
||||||
mu sync.RWMutex
|
|
||||||
models map[string]ModelFactory
|
models map[string]ModelFactory
|
||||||
metadata map[string]ModelMetadata
|
metadata map[string]ModelMetadata
|
||||||
defaultModel string
|
defaultModel string
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModelRegistry creates a new model registry.
|
// NewModelRegistry creates a new model registry.
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ var bgeONNXConfig = ONNXConfig{
|
|||||||
type bgeModel struct {
|
type bgeModel struct {
|
||||||
tk *tokenizer.Tokenizer
|
tk *tokenizer.Tokenizer
|
||||||
session *ort.DynamicAdvancedSession
|
session *ort.DynamicAdvancedSession
|
||||||
|
libDir string
|
||||||
|
config ONNXConfig
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
libDir string // temp directory containing extracted libraries
|
|
||||||
config ONNXConfig // ONNX configuration for this model
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compile-time check that bgeModel implements EmbeddingModel
|
// Compile-time check that bgeModel implements EmbeddingModel
|
||||||
@@ -70,8 +70,8 @@ func newBGEModel() (EmbeddingModel, error) {
|
|||||||
ort.SetSharedLibraryPath(libPath)
|
ort.SetSharedLibraryPath(libPath)
|
||||||
|
|
||||||
// Initialize ONNX runtime
|
// Initialize ONNX runtime
|
||||||
if err := ort.InitializeEnvironment(); err != nil {
|
if initErr := ort.InitializeEnvironment(); initErr != nil {
|
||||||
return nil, fmt.Errorf("initialize ONNX runtime: %w", err)
|
return nil, fmt.Errorf("initialize ONNX runtime: %w", initErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load tokenizer from embedded data
|
// Load tokenizer from embedded data
|
||||||
@@ -292,19 +292,19 @@ func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create input_ids tensor: %w", err)
|
return nil, fmt.Errorf("create input_ids tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer inputIdsTensor.Destroy()
|
defer func() { _ = inputIdsTensor.Destroy() }()
|
||||||
|
|
||||||
attentionMaskTensor, err := ort.NewTensor(inputShape, attentionMaskData)
|
attentionMaskTensor, err := ort.NewTensor(inputShape, attentionMaskData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create attention_mask tensor: %w", err)
|
return nil, fmt.Errorf("create attention_mask tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer attentionMaskTensor.Destroy()
|
defer func() { _ = attentionMaskTensor.Destroy() }()
|
||||||
|
|
||||||
tokenTypeIdsTensor, err := ort.NewTensor(inputShape, tokenTypeIdsData)
|
tokenTypeIdsTensor, err := ort.NewTensor(inputShape, tokenTypeIdsData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create token_type_ids tensor: %w", err)
|
return nil, fmt.Errorf("create token_type_ids tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer tokenTypeIdsTensor.Destroy()
|
defer func() { _ = tokenTypeIdsTensor.Destroy() }()
|
||||||
|
|
||||||
// Create output tensor based on pooling strategy
|
// Create output tensor based on pooling strategy
|
||||||
var outputShape ort.Shape
|
var outputShape ort.Shape
|
||||||
@@ -324,7 +324,7 @@ func (m *bgeModel) computeBatch(sentences []string) ([][]float32, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create output tensor: %w", err)
|
return nil, fmt.Errorf("create output tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer outputTensor.Destroy()
|
defer func() { _ = outputTensor.Destroy() }()
|
||||||
|
|
||||||
// Run inference
|
// Run inference
|
||||||
inputTensors := []ort.Value{inputIdsTensor, attentionMaskTensor, tokenTypeIdsTensor}
|
inputTensors := []ort.Value{inputIdsTensor, attentionMaskTensor, tokenTypeIdsTensor}
|
||||||
|
|||||||
+10
-10
@@ -15,10 +15,10 @@ import (
|
|||||||
|
|
||||||
// Server is the MCP server that exposes search tools.
|
// Server is the MCP server that exposes search tools.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
searchMgr *search.Manager
|
|
||||||
version string
|
|
||||||
stdin io.Reader
|
stdin io.Reader
|
||||||
stdout io.Writer
|
stdout io.Writer
|
||||||
|
searchMgr *search.Manager
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new MCP server.
|
// NewServer creates a new MCP server.
|
||||||
@@ -41,17 +41,17 @@ type Request struct {
|
|||||||
|
|
||||||
// Response represents a JSON-RPC response.
|
// Response represents a JSON-RPC response.
|
||||||
type Response struct {
|
type Response struct {
|
||||||
JSONRPC string `json:"jsonrpc"`
|
|
||||||
ID any `json:"id"`
|
ID any `json:"id"`
|
||||||
Result any `json:"result,omitempty"`
|
Result any `json:"result,omitempty"`
|
||||||
Error *Error `json:"error,omitempty"`
|
Error *Error `json:"error,omitempty"`
|
||||||
|
JSONRPC string `json:"jsonrpc"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error represents a JSON-RPC error.
|
// Error represents a JSON-RPC error.
|
||||||
type Error struct {
|
type Error struct {
|
||||||
Code int `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Data any `json:"data,omitempty"`
|
Data any `json:"data,omitempty"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Code int `json:"code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToolCallParams represents parameters for tools/call method.
|
// ToolCallParams represents parameters for tools/call method.
|
||||||
@@ -62,9 +62,9 @@ type ToolCallParams struct {
|
|||||||
|
|
||||||
// Tool represents an MCP tool definition.
|
// Tool represents an MCP tool definition.
|
||||||
type Tool struct {
|
type Tool struct {
|
||||||
|
InputSchema map[string]any `json:"inputSchema"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
InputSchema map[string]any `json:"inputSchema"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run starts the MCP server loop.
|
// Run starts the MCP server loop.
|
||||||
@@ -440,17 +440,17 @@ func (s *Server) callTool(ctx context.Context, name string, args json.RawMessage
|
|||||||
|
|
||||||
// TimelineParams represents parameters for timeline operations.
|
// TimelineParams represents parameters for timeline operations.
|
||||||
type TimelineParams struct {
|
type TimelineParams struct {
|
||||||
AnchorID int64 `json:"anchor_id"`
|
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Before int `json:"before"`
|
|
||||||
After int `json:"after"`
|
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
ObsType string `json:"obs_type"`
|
ObsType string `json:"obs_type"`
|
||||||
Concepts string `json:"concepts"`
|
Concepts string `json:"concepts"`
|
||||||
Files string `json:"files"`
|
Files string `json:"files"`
|
||||||
|
Format string `json:"format"`
|
||||||
|
AnchorID int64 `json:"anchor_id"`
|
||||||
|
Before int `json:"before"`
|
||||||
|
After int `json:"after"`
|
||||||
DateStart int64 `json:"dateStart"`
|
DateStart int64 `json:"dateStart"`
|
||||||
DateEnd int64 `json:"dateEnd"`
|
DateEnd int64 `json:"dateEnd"`
|
||||||
Format string `json:"format"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTimeline handles timeline requests.
|
// handleTimeline handles timeline requests.
|
||||||
|
|||||||
+13
-15
@@ -34,8 +34,8 @@ func (s *ServerSuite) TestNewServer() {
|
|||||||
func TestRequest(t *testing.T) {
|
func TestRequest(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
req Request
|
|
||||||
expected string
|
expected string
|
||||||
|
req Request
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "initialize request",
|
name: "initialize request",
|
||||||
@@ -137,10 +137,10 @@ func TestResponse(t *testing.T) {
|
|||||||
|
|
||||||
// TestError tests Error struct.
|
// TestError tests Error struct.
|
||||||
func TestError(t *testing.T) {
|
func TestError(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct { //nolint:govet
|
||||||
name string
|
|
||||||
err Error
|
err Error
|
||||||
expected string
|
expected string
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "parse error",
|
name: "parse error",
|
||||||
@@ -365,11 +365,11 @@ func TestHandleRequest(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
req *Request
|
req *Request
|
||||||
expectError bool
|
name string
|
||||||
errorCode int
|
|
||||||
errorMessage string
|
errorMessage string
|
||||||
|
errorCode int
|
||||||
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "initialize method",
|
name: "initialize method",
|
||||||
@@ -683,10 +683,8 @@ func TestRun_MultipleRequests(t *testing.T) {
|
|||||||
func TestHandleTimeline_Defaults(t *testing.T) {
|
func TestHandleTimeline_Defaults(t *testing.T) {
|
||||||
// Test that handleTimeline sets default before/after values
|
// Test that handleTimeline sets default before/after values
|
||||||
params := TimelineParams{
|
params := TimelineParams{
|
||||||
AnchorID: 0,
|
Before: 0,
|
||||||
Query: "",
|
After: 0,
|
||||||
Before: 0,
|
|
||||||
After: 0,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simulate the default value assignment from handleTimeline
|
// Simulate the default value assignment from handleTimeline
|
||||||
@@ -753,13 +751,13 @@ func TestServerStdinStdoutConfig(t *testing.T) {
|
|||||||
// TestResponseIDTypes tests that response IDs can be various types.
|
// TestResponseIDTypes tests that response IDs can be various types.
|
||||||
func TestResponseIDTypes(t *testing.T) {
|
func TestResponseIDTypes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
id any
|
id any
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{"integer id", 1},
|
{name: "integer id", id: 1},
|
||||||
{"string id", "abc-123"},
|
{name: "string id", id: "abc-123"},
|
||||||
{"float id", 1.5},
|
{name: "float id", id: 1.5},
|
||||||
{"null id", nil},
|
{name: "null id", id: nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -38,21 +38,15 @@ type PatternSyncFunc func(pattern *models.Pattern)
|
|||||||
|
|
||||||
// Detector detects and tracks recurring patterns across observations.
|
// Detector detects and tracks recurring patterns across observations.
|
||||||
type Detector struct {
|
type Detector struct {
|
||||||
config DetectorConfig
|
ctx context.Context
|
||||||
patternStore *sqlite.PatternStore
|
patternStore *sqlite.PatternStore
|
||||||
observationStore *sqlite.ObservationStore
|
observationStore *sqlite.ObservationStore
|
||||||
|
syncFunc PatternSyncFunc
|
||||||
// Vector sync callback
|
candidates map[string]*candidatePattern
|
||||||
syncFunc PatternSyncFunc
|
cancel context.CancelFunc
|
||||||
|
config DetectorConfig
|
||||||
// Candidate tracking (patterns not yet confirmed)
|
wg sync.WaitGroup
|
||||||
candidates map[string]*candidatePattern
|
candidatesMu sync.RWMutex
|
||||||
candidatesMu sync.RWMutex
|
|
||||||
|
|
||||||
// Background analysis
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSyncFunc sets the callback for syncing patterns to vector store.
|
// SetSyncFunc sets the callback for syncing patterns to vector store.
|
||||||
@@ -62,11 +56,11 @@ func (d *Detector) SetSyncFunc(fn PatternSyncFunc) {
|
|||||||
|
|
||||||
// candidatePattern tracks a potential pattern before it reaches frequency threshold.
|
// candidatePattern tracks a potential pattern before it reaches frequency threshold.
|
||||||
type candidatePattern struct {
|
type candidatePattern struct {
|
||||||
|
patternType models.PatternType
|
||||||
|
title string
|
||||||
signature []string
|
signature []string
|
||||||
observationIDs []int64
|
observationIDs []int64
|
||||||
projects []string
|
projects []string
|
||||||
patternType models.PatternType
|
|
||||||
title string
|
|
||||||
lastSeenEpoch int64
|
lastSeenEpoch int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,16 +331,16 @@ func TestDefaultConfig(t *testing.T) {
|
|||||||
func TestGeneratePatternName(t *testing.T) {
|
func TestGeneratePatternName(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
patternType models.PatternType
|
patternType models.PatternType
|
||||||
signature []string
|
|
||||||
title string
|
title string
|
||||||
wantPrefix string
|
wantPrefix string
|
||||||
|
signature []string
|
||||||
}{
|
}{
|
||||||
{models.PatternTypeBug, []string{"nil", "error"}, "", "Bug Pattern:"},
|
{patternType: models.PatternTypeBug, signature: []string{"nil", "error"}, title: "", wantPrefix: "Bug Pattern:"},
|
||||||
{models.PatternTypeRefactor, []string{"extract"}, "", "Refactor Pattern:"},
|
{patternType: models.PatternTypeRefactor, signature: []string{"extract"}, title: "", wantPrefix: "Refactor Pattern:"},
|
||||||
{models.PatternTypeArchitecture, []string{"service"}, "", "Architecture Pattern:"},
|
{patternType: models.PatternTypeArchitecture, signature: []string{"service"}, title: "", wantPrefix: "Architecture Pattern:"},
|
||||||
{models.PatternTypeAntiPattern, []string{"god-class"}, "", "Anti-Pattern:"},
|
{patternType: models.PatternTypeAntiPattern, signature: []string{"god-class"}, title: "", wantPrefix: "Anti-Pattern:"},
|
||||||
{models.PatternTypeBestPractice, []string{"testing"}, "", "Best Practice:"},
|
{patternType: models.PatternTypeBestPractice, signature: []string{"testing"}, title: "", wantPrefix: "Best Practice:"},
|
||||||
{models.PatternTypeBug, []string{}, "Short Title", "Short Title"}, // Use title directly
|
{patternType: models.PatternTypeBug, signature: []string{}, title: "Short Title", wantPrefix: "Short Title"}, // Use title directly
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -30,24 +30,24 @@ const (
|
|||||||
|
|
||||||
// Candidate represents a search result candidate for reranking.
|
// Candidate represents a search result candidate for reranking.
|
||||||
type Candidate struct {
|
type Candidate struct {
|
||||||
ID string // Document ID
|
Metadata map[string]any
|
||||||
Content string // Document text content for scoring
|
RerankInfo map[string]float64
|
||||||
Score float64 // Original bi-encoder similarity score
|
ID string
|
||||||
Metadata map[string]any // Preserved metadata
|
Content string
|
||||||
RerankInfo map[string]float64 // Reranking debug info (optional)
|
Score float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// RerankResult represents a reranked search result.
|
// RerankResult represents a reranked search result.
|
||||||
type RerankResult struct {
|
type RerankResult struct {
|
||||||
ID string // Document ID
|
Metadata map[string]any
|
||||||
Content string // Document text content
|
ID string
|
||||||
OriginalScore float64 // Original bi-encoder score
|
Content string
|
||||||
RerankScore float64 // Cross-encoder relevance score
|
OriginalScore float64
|
||||||
CombinedScore float64 // Weighted combination of scores
|
RerankScore float64
|
||||||
Metadata map[string]any // Preserved metadata
|
CombinedScore float64
|
||||||
OriginalRank int // Position before reranking (1-indexed)
|
OriginalRank int
|
||||||
RerankRank int // Position after reranking (1-indexed)
|
RerankRank int
|
||||||
RankImprovement int // How much the rank improved (positive = moved up)
|
RankImprovement int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service provides cross-encoder reranking functionality.
|
// Service provides cross-encoder reranking functionality.
|
||||||
@@ -297,19 +297,19 @@ func (s *Service) scoreAll(query string, candidates []Candidate) ([]float64, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create input_ids tensor: %w", err)
|
return nil, fmt.Errorf("create input_ids tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer inputIdsTensor.Destroy()
|
defer func() { _ = inputIdsTensor.Destroy() }()
|
||||||
|
|
||||||
attentionMaskTensor, err := ort.NewTensor(inputShape, attentionMaskData)
|
attentionMaskTensor, err := ort.NewTensor(inputShape, attentionMaskData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create attention_mask tensor: %w", err)
|
return nil, fmt.Errorf("create attention_mask tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer attentionMaskTensor.Destroy()
|
defer func() { _ = attentionMaskTensor.Destroy() }()
|
||||||
|
|
||||||
tokenTypeIdsTensor, err := ort.NewTensor(inputShape, tokenTypeIdsData)
|
tokenTypeIdsTensor, err := ort.NewTensor(inputShape, tokenTypeIdsData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create token_type_ids tensor: %w", err)
|
return nil, fmt.Errorf("create token_type_ids tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer tokenTypeIdsTensor.Destroy()
|
defer func() { _ = tokenTypeIdsTensor.Destroy() }()
|
||||||
|
|
||||||
// Cross-encoder outputs [batch, 1] logits
|
// Cross-encoder outputs [batch, 1] logits
|
||||||
outputShape := ort.NewShape(int64(batchSize), 1)
|
outputShape := ort.NewShape(int64(batchSize), 1)
|
||||||
@@ -317,7 +317,7 @@ func (s *Service) scoreAll(query string, candidates []Candidate) ([]float64, err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create output tensor: %w", err)
|
return nil, fmt.Errorf("create output tensor: %w", err)
|
||||||
}
|
}
|
||||||
defer outputTensor.Destroy()
|
defer func() { _ = outputTensor.Destroy() }()
|
||||||
|
|
||||||
// Run inference
|
// Run inference
|
||||||
inputTensors := []ort.Value{inputIdsTensor, attentionMaskTensor, tokenTypeIdsTensor}
|
inputTensors := []ort.Value{inputIdsTensor, attentionMaskTensor, tokenTypeIdsTensor}
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ type ObservationStore interface {
|
|||||||
|
|
||||||
// Recalculator periodically recalculates importance scores for observations.
|
// Recalculator periodically recalculates importance scores for observations.
|
||||||
type Recalculator struct {
|
type Recalculator struct {
|
||||||
|
log zerolog.Logger
|
||||||
store ObservationStore
|
store ObservationStore
|
||||||
calculator *Calculator
|
calculator *Calculator
|
||||||
log zerolog.Logger
|
|
||||||
interval time.Duration
|
|
||||||
batchSize int
|
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
doneCh chan struct{}
|
doneCh chan struct{}
|
||||||
|
interval time.Duration
|
||||||
|
batchSize int
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,14 +16,14 @@ import (
|
|||||||
|
|
||||||
// MockObservationStore is a mock implementation of ObservationStore for testing.
|
// MockObservationStore is a mock implementation of ObservationStore for testing.
|
||||||
type MockObservationStore struct {
|
type MockObservationStore struct {
|
||||||
mu sync.Mutex
|
|
||||||
observations []*models.Observation
|
|
||||||
scores map[int64]float64
|
|
||||||
conceptWeights map[string]float64
|
|
||||||
updateErr error
|
updateErr error
|
||||||
getErr error
|
getErr error
|
||||||
getConceptsErr error
|
getConceptsErr error
|
||||||
|
scores map[int64]float64
|
||||||
|
conceptWeights map[string]float64
|
||||||
|
observations []*models.Observation
|
||||||
updateScoresCalls int
|
updateScoresCalls int
|
||||||
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMockObservationStore() *MockObservationStore {
|
func NewMockObservationStore() *MockObservationStore {
|
||||||
|
|||||||
@@ -30,25 +30,25 @@ const (
|
|||||||
// ExpandedQuery represents a query variant with metadata.
|
// ExpandedQuery represents a query variant with metadata.
|
||||||
type ExpandedQuery struct {
|
type ExpandedQuery struct {
|
||||||
Query string `json:"query"`
|
Query string `json:"query"`
|
||||||
Weight float64 `json:"weight"` // Weight for result merging (0.0-1.0)
|
Source string `json:"source"`
|
||||||
Source string `json:"source"` // Where this expansion came from
|
Intent QueryIntent `json:"intent"`
|
||||||
Intent QueryIntent `json:"intent"` // Detected intent
|
Weight float64 `json:"weight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expander provides context-aware query expansion.
|
// Expander provides context-aware query expansion.
|
||||||
type Expander struct {
|
type Expander struct {
|
||||||
embedSvc *embedding.Service
|
embedSvc *embedding.Service
|
||||||
vocabulary []VocabEntry // Known vocabulary from observations
|
|
||||||
vocabVectors [][]float32 // Embeddings for vocabulary entries
|
|
||||||
vocabMu sync.RWMutex // Protects vocabulary
|
|
||||||
intentPatterns map[QueryIntent][]*regexp.Regexp
|
intentPatterns map[QueryIntent][]*regexp.Regexp
|
||||||
|
vocabulary []VocabEntry
|
||||||
|
vocabVectors [][]float32
|
||||||
|
vocabMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// VocabEntry represents a vocabulary term from observations.
|
// VocabEntry represents a vocabulary term from observations.
|
||||||
type VocabEntry struct {
|
type VocabEntry struct {
|
||||||
Term string // The term itself
|
Term string
|
||||||
Weight float64 // How common/important this term is (0.0-1.0)
|
Source string
|
||||||
Source string // Where it came from (title, concept, narrative)
|
Weight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config holds expander configuration.
|
// Config holds expander configuration.
|
||||||
|
|||||||
@@ -88,16 +88,16 @@ func (s *ExpanderSuite) TestExpand() {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
query string
|
query string
|
||||||
|
expectedIntent QueryIntent
|
||||||
minExpansions int
|
minExpansions int
|
||||||
hasOriginal bool
|
hasOriginal bool
|
||||||
expectedIntent QueryIntent
|
|
||||||
}{
|
}{
|
||||||
{"question", "how do I implement auth", 1, true, IntentQuestion},
|
{name: "question", query: "how do I implement auth", expectedIntent: IntentQuestion, minExpansions: 1, hasOriginal: true},
|
||||||
{"error", "fix the bug in login", 1, true, IntentError},
|
{name: "error", query: "fix the bug in login", expectedIntent: IntentError, minExpansions: 1, hasOriginal: true},
|
||||||
{"implementation", "implement user handler", 1, true, IntentImplementation},
|
{name: "implementation", query: "implement user handler", expectedIntent: IntentImplementation, minExpansions: 1, hasOriginal: true},
|
||||||
{"architecture", "architecture design", 1, true, IntentArchitecture},
|
{name: "architecture", query: "architecture design", expectedIntent: IntentArchitecture, minExpansions: 1, hasOriginal: true},
|
||||||
{"general", "database connection", 1, true, IntentGeneral},
|
{name: "general", query: "database connection", expectedIntent: IntentGeneral, minExpansions: 1, hasOriginal: true},
|
||||||
{"empty", "", 0, false, IntentGeneral},
|
{name: "empty", query: "", expectedIntent: IntentGeneral, minExpansions: 0, hasOriginal: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -392,13 +392,13 @@ func TestTruncate(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
{"short", "hello", 10, "hello"},
|
{name: "short", input: "hello", maxLen: 10, expected: "hello"},
|
||||||
{"exact", "hello", 5, "hello"},
|
{name: "exact", input: "hello", maxLen: 5, expected: "hello"},
|
||||||
{"long", "hello world", 5, "hello..."},
|
{name: "long", input: "hello world", maxLen: 5, expected: "hello..."},
|
||||||
{"empty", "", 10, ""},
|
{name: "empty", input: "", maxLen: 10, expected: ""},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -517,16 +517,16 @@ func TestTruncate_TableDriven(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
{"short_string", "hello", 10, "hello"},
|
{name: "short_string", input: "hello", maxLen: 10, expected: "hello"},
|
||||||
{"exact_length", "hello", 5, "hello"},
|
{name: "exact_length", input: "hello", maxLen: 5, expected: "hello"},
|
||||||
{"long_string", "hello world", 5, "hello..."},
|
{name: "long_string", input: "hello world", maxLen: 5, expected: "hello..."},
|
||||||
{"empty_string", "", 10, ""},
|
{name: "empty_string", input: "", maxLen: 10, expected: ""},
|
||||||
{"whitespace_only", " ", 10, ""},
|
{name: "whitespace_only", input: " ", maxLen: 10, expected: ""},
|
||||||
{"with_leading_space", " hello ", 10, "hello"},
|
{name: "with_leading_space", input: " hello ", maxLen: 10, expected: "hello"},
|
||||||
{"very_long", "this is a very long string that should be truncated", 20, "this is a very long ..."},
|
{name: "very_long", input: "this is a very long string that should be truncated", maxLen: 20, expected: "this is a very long ..."},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
+15
-15
@@ -35,41 +35,41 @@ func NewManager(
|
|||||||
|
|
||||||
// SearchParams contains parameters for unified search.
|
// SearchParams contains parameters for unified search.
|
||||||
type SearchParams struct {
|
type SearchParams struct {
|
||||||
Query string
|
Format string
|
||||||
Type string // "observations", "sessions", "prompts", or empty for all
|
Type string
|
||||||
Project string
|
Project string
|
||||||
ObsType string // Observation type filter
|
ObsType string
|
||||||
Concepts string
|
Concepts string
|
||||||
Files string
|
Files string
|
||||||
|
Query string
|
||||||
|
Scope string
|
||||||
|
OrderBy string
|
||||||
DateStart int64
|
DateStart int64
|
||||||
DateEnd int64
|
|
||||||
OrderBy string // "relevance", "date_desc", "date_asc"
|
|
||||||
Limit int
|
|
||||||
Offset int
|
Offset int
|
||||||
Format string // "index" or "full"
|
Limit int
|
||||||
Scope string // "project", "global", or empty for project+global
|
DateEnd int64
|
||||||
IncludeGlobal bool // If true, include global observations along with project-scoped
|
IncludeGlobal bool
|
||||||
ExcludeSuperseded bool // If true, exclude observations that have been superseded
|
ExcludeSuperseded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchResult represents a unified search result.
|
// SearchResult represents a unified search result.
|
||||||
type SearchResult struct {
|
type SearchResult struct {
|
||||||
Type string `json:"type"` // "observation", "session", "prompt"
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
ID int64 `json:"id"`
|
Type string `json:"type"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Content string `json:"content,omitempty"`
|
Content string `json:"content,omitempty"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
Scope string `json:"scope,omitempty"` // "project" or "global"
|
Scope string `json:"scope,omitempty"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
CreatedAt int64 `json:"created_at_epoch"`
|
CreatedAt int64 `json:"created_at_epoch"`
|
||||||
Score float64 `json:"score,omitempty"`
|
Score float64 `json:"score,omitempty"`
|
||||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnifiedSearchResult contains the combined search results.
|
// UnifiedSearchResult contains the combined search results.
|
||||||
type UnifiedSearchResult struct {
|
type UnifiedSearchResult struct {
|
||||||
|
Query string `json:"query,omitempty"`
|
||||||
Results []SearchResult `json:"results"`
|
Results []SearchResult `json:"results"`
|
||||||
TotalCount int `json:"total_count"`
|
TotalCount int `json:"total_count"`
|
||||||
Query string `json:"query,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnifiedSearch performs a unified search across all document types.
|
// UnifiedSearch performs a unified search across all document types.
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package search
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -94,8 +93,8 @@ func TestTruncate(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "short string no truncation",
|
name: "short string no truncation",
|
||||||
@@ -147,11 +146,11 @@ func TestTruncate(t *testing.T) {
|
|||||||
func TestObservationToResult(t *testing.T) {
|
func TestObservationToResult(t *testing.T) {
|
||||||
m := NewManager(nil, nil, nil, nil)
|
m := NewManager(nil, nil, nil, nil)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct { //nolint:govet
|
||||||
name string
|
|
||||||
obs *models.Observation
|
obs *models.Observation
|
||||||
format string
|
|
||||||
expected SearchResult
|
expected SearchResult
|
||||||
|
format string
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "full format with all fields",
|
name: "full format with all fields",
|
||||||
@@ -239,11 +238,11 @@ func TestObservationToResult(t *testing.T) {
|
|||||||
func TestSummaryToResult(t *testing.T) {
|
func TestSummaryToResult(t *testing.T) {
|
||||||
m := NewManager(nil, nil, nil, nil)
|
m := NewManager(nil, nil, nil, nil)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct { //nolint:govet
|
||||||
name string
|
|
||||||
summary *models.SessionSummary
|
summary *models.SessionSummary
|
||||||
format string
|
|
||||||
expected SearchResult
|
expected SearchResult
|
||||||
|
format string
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "full format with all fields",
|
name: "full format with all fields",
|
||||||
@@ -321,11 +320,11 @@ func TestSummaryToResult(t *testing.T) {
|
|||||||
func TestPromptToResult(t *testing.T) {
|
func TestPromptToResult(t *testing.T) {
|
||||||
m := NewManager(nil, nil, nil, nil)
|
m := NewManager(nil, nil, nil, nil)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct { //nolint:govet
|
||||||
name string
|
|
||||||
prompt *models.UserPromptWithSession
|
prompt *models.UserPromptWithSession
|
||||||
format string
|
|
||||||
expected SearchResult
|
expected SearchResult
|
||||||
|
format string
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "full format with content",
|
name: "full format with content",
|
||||||
@@ -406,9 +405,9 @@ func TestPromptToResult(t *testing.T) {
|
|||||||
func TestSearchParamsValidation(t *testing.T) {
|
func TestSearchParamsValidation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
expectedOrder string
|
||||||
params SearchParams
|
params SearchParams
|
||||||
expectedLimit int
|
expectedLimit int
|
||||||
expectedOrder string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "default limit applied",
|
name: "default limit applied",
|
||||||
@@ -731,23 +730,21 @@ func TestPromptToResultFormats(t *testing.T) {
|
|||||||
func TestSearchParamsDefaults(t *testing.T) {
|
func TestSearchParamsDefaults(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
initialLimit int
|
|
||||||
initialOrder string
|
initialOrder string
|
||||||
expectedLimit int
|
|
||||||
expectedOrder string
|
expectedOrder string
|
||||||
|
initialLimit int
|
||||||
|
expectedLimit int
|
||||||
}{
|
}{
|
||||||
{"zero_limit", 0, "", 20, "date_desc"},
|
{name: "zero_limit", initialLimit: 0, initialOrder: "", expectedLimit: 20, expectedOrder: "date_desc"},
|
||||||
{"negative_limit", -5, "", 20, "date_desc"},
|
{name: "negative_limit", initialLimit: -5, initialOrder: "", expectedLimit: 20, expectedOrder: "date_desc"},
|
||||||
{"over_100_limit", 150, "", 100, "date_desc"},
|
{name: "over_100_limit", initialLimit: 150, initialOrder: "", expectedLimit: 100, expectedOrder: "date_desc"},
|
||||||
{"valid_limit_50", 50, "relevance", 50, "relevance"},
|
{name: "valid_limit_50", initialLimit: 50, initialOrder: "relevance", expectedLimit: 50, expectedOrder: "relevance"},
|
||||||
{"custom_order", 30, "date_asc", 30, "date_asc"},
|
{name: "custom_order", initialLimit: 30, initialOrder: "date_asc", expectedLimit: 30, expectedOrder: "date_asc"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
params := SearchParams{
|
params := SearchParams{
|
||||||
Query: "test",
|
|
||||||
Project: "project",
|
|
||||||
Limit: tt.initialLimit,
|
Limit: tt.initialLimit,
|
||||||
OrderBy: tt.initialOrder,
|
OrderBy: tt.initialOrder,
|
||||||
}
|
}
|
||||||
@@ -774,18 +771,18 @@ func TestTruncateEdgeCases(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
// Unicode strings - uses byte length so ensure maxLen accommodates full string
|
// Unicode strings - uses byte length so ensure maxLen accommodates full string
|
||||||
{"unicode_string_no_truncate", "日本語テスト", 20, "日本語テスト"},
|
{name: "unicode_string_no_truncate", input: "日本語テスト", maxLen: 20, expected: "日本語テスト"},
|
||||||
{"mixed_unicode_no_truncate", "Hello世界", 15, "Hello世界"},
|
{name: "mixed_unicode_no_truncate", input: "Hello世界", maxLen: 15, expected: "Hello世界"},
|
||||||
// ASCII truncation
|
// ASCII truncation
|
||||||
{"ascii_truncate", "Hello World", 5, "Hello..."},
|
{name: "ascii_truncate", input: "Hello World", maxLen: 5, expected: "Hello..."},
|
||||||
{"only_whitespace", " ", 10, ""},
|
{name: "only_whitespace", input: " ", maxLen: 10, expected: ""},
|
||||||
{"tabs_and_newlines", "\t\n \t", 10, ""},
|
{name: "tabs_and_newlines", input: "\t\n \t", maxLen: 10, expected: ""},
|
||||||
{"newlines_with_content", "\n\nhello\n\n", 10, "hello"},
|
{name: "newlines_with_content", input: "\n\nhello\n\n", maxLen: 10, expected: "hello"},
|
||||||
{"zero_max_len", "hello", 0, "..."},
|
{name: "zero_max_len", input: "hello", maxLen: 0, expected: "..."},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -812,8 +809,6 @@ func TestUnifiedSearchResultEmpty(t *testing.T) {
|
|||||||
// TestSearchResultMetadata tests SearchResult metadata handling.
|
// TestSearchResultMetadata tests SearchResult metadata handling.
|
||||||
func TestSearchResultMetadata(t *testing.T) {
|
func TestSearchResultMetadata(t *testing.T) {
|
||||||
result := SearchResult{
|
result := SearchResult{
|
||||||
Type: "observation",
|
|
||||||
ID: 1,
|
|
||||||
Metadata: map[string]interface{}{
|
Metadata: map[string]interface{}{
|
||||||
"obs_type": "discovery",
|
"obs_type": "discovery",
|
||||||
"scope": "project",
|
"scope": "project",
|
||||||
@@ -835,10 +830,7 @@ func TestSearchResultTypes(t *testing.T) {
|
|||||||
for _, typ := range types {
|
for _, typ := range types {
|
||||||
t.Run(typ, func(t *testing.T) {
|
t.Run(typ, func(t *testing.T) {
|
||||||
result := SearchResult{
|
result := SearchResult{
|
||||||
Type: typ,
|
Type: typ,
|
||||||
ID: 1,
|
|
||||||
Project: "test",
|
|
||||||
CreatedAt: time.Now().UnixMilli(),
|
|
||||||
}
|
}
|
||||||
assert.Equal(t, typ, result.Type)
|
assert.Equal(t, typ, result.Type)
|
||||||
})
|
})
|
||||||
@@ -952,8 +944,8 @@ func TestSearchParams_OrderByValues(t *testing.T) {
|
|||||||
for _, order := range validOrders {
|
for _, order := range validOrders {
|
||||||
t.Run("order_"+order, func(t *testing.T) {
|
t.Run("order_"+order, func(t *testing.T) {
|
||||||
params := SearchParams{
|
params := SearchParams{
|
||||||
Query: "test",
|
Query: "test", //nolint:govet
|
||||||
Project: "test",
|
Project: "test", //nolint:govet
|
||||||
OrderBy: order,
|
OrderBy: order,
|
||||||
}
|
}
|
||||||
assert.Equal(t, order, params.OrderBy)
|
assert.Equal(t, order, params.OrderBy)
|
||||||
@@ -968,9 +960,7 @@ func TestSearchParams_TypeValues(t *testing.T) {
|
|||||||
for _, typ := range validTypes {
|
for _, typ := range validTypes {
|
||||||
t.Run("type_"+typ, func(t *testing.T) {
|
t.Run("type_"+typ, func(t *testing.T) {
|
||||||
params := SearchParams{
|
params := SearchParams{
|
||||||
Query: "test",
|
Type: typ,
|
||||||
Project: "test",
|
|
||||||
Type: typ,
|
|
||||||
}
|
}
|
||||||
assert.Equal(t, typ, params.Type)
|
assert.Equal(t, typ, params.Type)
|
||||||
})
|
})
|
||||||
@@ -984,8 +974,8 @@ func TestSearchParams_ScopeValues(t *testing.T) {
|
|||||||
for _, scope := range validScopes {
|
for _, scope := range validScopes {
|
||||||
t.Run("scope_"+scope, func(t *testing.T) {
|
t.Run("scope_"+scope, func(t *testing.T) {
|
||||||
params := SearchParams{
|
params := SearchParams{
|
||||||
Query: "test",
|
Query: "test", //nolint:govet
|
||||||
Project: "test",
|
Project: "test", //nolint:govet
|
||||||
Scope: scope,
|
Scope: scope,
|
||||||
}
|
}
|
||||||
assert.Equal(t, scope, params.Scope)
|
assert.Equal(t, scope, params.Scope)
|
||||||
@@ -1000,8 +990,8 @@ func TestSearchParams_FormatValues(t *testing.T) {
|
|||||||
for _, format := range validFormats {
|
for _, format := range validFormats {
|
||||||
t.Run("format_"+format, func(t *testing.T) {
|
t.Run("format_"+format, func(t *testing.T) {
|
||||||
params := SearchParams{
|
params := SearchParams{
|
||||||
Query: "test",
|
Query: "test", //nolint:govet
|
||||||
Project: "test",
|
Project: "test", //nolint:govet
|
||||||
Format: format,
|
Format: format,
|
||||||
}
|
}
|
||||||
assert.Equal(t, format, params.Format)
|
assert.Equal(t, format, params.Format)
|
||||||
@@ -1020,7 +1010,7 @@ func TestUnifiedSearchResult_MultipleResults(t *testing.T) {
|
|||||||
result := UnifiedSearchResult{
|
result := UnifiedSearchResult{
|
||||||
Results: results,
|
Results: results,
|
||||||
TotalCount: 3,
|
TotalCount: 3,
|
||||||
Query: "test query",
|
Query: "test query", //nolint:govet
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(t, result.Results, 3)
|
assert.Len(t, result.Results, 3)
|
||||||
@@ -1040,8 +1030,8 @@ func TestSearchResult_Metadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
result := SearchResult{
|
result := SearchResult{
|
||||||
Type: "observation",
|
Type: "observation", //nolint:govet
|
||||||
ID: 1,
|
ID: 1, //nolint:govet
|
||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1066,8 +1056,6 @@ func TestSearchResult_Scores(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := SearchResult{
|
result := SearchResult{
|
||||||
Type: "observation",
|
|
||||||
ID: 1,
|
|
||||||
Score: tt.score,
|
Score: tt.score,
|
||||||
}
|
}
|
||||||
assert.Equal(t, tt.score, result.Score)
|
assert.Equal(t, tt.score, result.Score)
|
||||||
|
|||||||
+14
-15
@@ -33,11 +33,11 @@ const (
|
|||||||
|
|
||||||
// Release represents a GitHub release.
|
// Release represents a GitHub release.
|
||||||
type Release struct {
|
type Release struct {
|
||||||
|
PublishedAt time.Time `json:"published_at"`
|
||||||
TagName string `json:"tag_name"`
|
TagName string `json:"tag_name"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PublishedAt time.Time `json:"published_at"`
|
|
||||||
Assets []Asset `json:"assets"`
|
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
|
Assets []Asset `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asset represents a release asset.
|
// Asset represents a release asset.
|
||||||
@@ -49,15 +49,15 @@ type Asset struct {
|
|||||||
|
|
||||||
// UpdateInfo contains information about an available update.
|
// UpdateInfo contains information about an available update.
|
||||||
type UpdateInfo struct {
|
type UpdateInfo struct {
|
||||||
Available bool `json:"available"`
|
PublishedAt time.Time `json:"published_at,omitempty"`
|
||||||
CurrentVersion string `json:"current_version"`
|
CurrentVersion string `json:"current_version"`
|
||||||
LatestVersion string `json:"latest_version"`
|
LatestVersion string `json:"latest_version"`
|
||||||
ReleaseNotes string `json:"release_notes,omitempty"`
|
ReleaseNotes string `json:"release_notes,omitempty"`
|
||||||
PublishedAt time.Time `json:"published_at,omitempty"`
|
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
ChecksumsURL string `json:"checksums_url,omitempty"`
|
ChecksumsURL string `json:"checksums_url,omitempty"`
|
||||||
BundleURL string `json:"bundle_url,omitempty"` // Sigstore bundle (.sigstore.json)
|
BundleURL string `json:"bundle_url,omitempty"`
|
||||||
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
||||||
|
Available bool `json:"available"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InstallScriptURL is the URL to the remote installation script.
|
// InstallScriptURL is the URL to the remote installation script.
|
||||||
@@ -74,23 +74,22 @@ func GetManualUpdateCommand(version string) string {
|
|||||||
|
|
||||||
// UpdateStatus represents the current update status.
|
// UpdateStatus represents the current update status.
|
||||||
type UpdateStatus struct {
|
type UpdateStatus struct {
|
||||||
State string `json:"state"` // "idle", "checking", "downloading", "verifying", "applying", "done", "error"
|
State string `json:"state"`
|
||||||
Progress float64 `json:"progress"`
|
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
ManualUpdateCommand string `json:"manual_update_command,omitempty"` // Shown when update fails
|
ManualUpdateCommand string `json:"manual_update_command,omitempty"`
|
||||||
|
Progress float64 `json:"progress"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updater handles self-updates.
|
// Updater handles self-updates.
|
||||||
type Updater struct {
|
type Updater struct { //nolint:govet
|
||||||
|
httpClient *http.Client
|
||||||
|
cachedUpdate *UpdateInfo
|
||||||
|
lastCheck time.Time
|
||||||
|
status UpdateStatus
|
||||||
currentVersion string
|
currentVersion string
|
||||||
installDir string
|
installDir string
|
||||||
httpClient *http.Client
|
mu sync.RWMutex
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
status UpdateStatus
|
|
||||||
lastCheck time.Time
|
|
||||||
cachedUpdate *UpdateInfo
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Updater.
|
// New creates a new Updater.
|
||||||
|
|||||||
@@ -87,9 +87,9 @@ func (c *Client) AddDocuments(ctx context.Context, docs []Document) error {
|
|||||||
|
|
||||||
for i, doc := range docs {
|
for i, doc := range docs {
|
||||||
// Serialize embedding to blob format
|
// Serialize embedding to blob format
|
||||||
embBlob, err := sqlite_vec.SerializeFloat32(embeddings[i])
|
embBlob, serErr := sqlite_vec.SerializeFloat32(embeddings[i])
|
||||||
if err != nil {
|
if serErr != nil {
|
||||||
return fmt.Errorf("serialize embedding for %s: %w", doc.ID, err)
|
return fmt.Errorf("serialize embedding for %s: %w", doc.ID, serErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract metadata
|
// Extract metadata
|
||||||
@@ -212,8 +212,8 @@ func (c *Client) Query(ctx context.Context, query string, limit int, where map[s
|
|||||||
var sqliteID int64
|
var sqliteID int64
|
||||||
var docType, fieldType, project, scope sql.NullString
|
var docType, fieldType, project, scope sql.NullString
|
||||||
|
|
||||||
if err := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docType, &fieldType, &project, &scope); err != nil {
|
if scanErr := rows.Scan(&r.ID, &r.Distance, &sqliteID, &docType, &fieldType, &project, &scope); scanErr != nil {
|
||||||
return nil, fmt.Errorf("scan row: %w", err)
|
return nil, fmt.Errorf("scan row: %w", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Similarity = DistanceToSimilarity(r.Distance)
|
r.Similarity = DistanceToSimilarity(r.Distance)
|
||||||
@@ -319,11 +319,11 @@ func (c *Client) NeedsRebuild(ctx context.Context) (bool, string) {
|
|||||||
// StaleVectorInfo contains information about a vector that needs rebuilding.
|
// StaleVectorInfo contains information about a vector that needs rebuilding.
|
||||||
type StaleVectorInfo struct {
|
type StaleVectorInfo struct {
|
||||||
DocID string
|
DocID string
|
||||||
SQLiteID int64
|
|
||||||
DocType string
|
DocType string
|
||||||
FieldType string
|
FieldType string
|
||||||
Project string
|
Project string
|
||||||
Scope string
|
Scope string
|
||||||
|
SQLiteID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
|
// GetStaleVectors returns doc_ids of vectors with mismatched or null model versions.
|
||||||
@@ -352,8 +352,8 @@ func (c *Client) GetStaleVectors(ctx context.Context) ([]StaleVectorInfo, error)
|
|||||||
var sqliteID sql.NullInt64
|
var sqliteID sql.NullInt64
|
||||||
var docType, fieldType, project, scope sql.NullString
|
var docType, fieldType, project, scope sql.NullString
|
||||||
|
|
||||||
if err := rows.Scan(&info.DocID, &sqliteID, &docType, &fieldType, &project, &scope); err != nil {
|
if scanErr := rows.Scan(&info.DocID, &sqliteID, &docType, &fieldType, &project, &scope); scanErr != nil {
|
||||||
return nil, fmt.Errorf("scan row: %w", err)
|
return nil, fmt.Errorf("scan row: %w", scanErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
info.SQLiteID = sqliteID.Int64
|
info.SQLiteID = sqliteID.Int64
|
||||||
|
|||||||
@@ -8,21 +8,22 @@ const (
|
|||||||
DocTypeObservation DocType = "observation"
|
DocTypeObservation DocType = "observation"
|
||||||
DocTypeSessionSummary DocType = "session_summary"
|
DocTypeSessionSummary DocType = "session_summary"
|
||||||
DocTypeUserPrompt DocType = "user_prompt"
|
DocTypeUserPrompt DocType = "user_prompt"
|
||||||
|
DocTypeCodeChunk DocType = "code_chunk"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Document represents a document to store with vector embedding.
|
// Document represents a document to store with vector embedding.
|
||||||
type Document struct {
|
type Document struct {
|
||||||
|
Metadata map[string]any
|
||||||
ID string
|
ID string
|
||||||
Content string
|
Content string
|
||||||
Metadata map[string]any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryResult represents a search result from vector search.
|
// QueryResult represents a search result from vector search.
|
||||||
type QueryResult struct {
|
type QueryResult struct {
|
||||||
|
Metadata map[string]any
|
||||||
ID string
|
ID string
|
||||||
Distance float64
|
Distance float64
|
||||||
Similarity float64 // 1.0 = identical, 0.0 = opposite (derived from distance)
|
Similarity float64
|
||||||
Metadata map[string]any
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DistanceToSimilarity converts sqlite-vec cosine distance to similarity score.
|
// DistanceToSimilarity converts sqlite-vec cosine distance to similarity score.
|
||||||
|
|||||||
@@ -42,10 +42,10 @@ func TestQueryResult_Fields(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildWhereFilter(t *testing.T) {
|
func TestBuildWhereFilter(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
expected map[string]interface{}
|
||||||
name string
|
name string
|
||||||
docType DocType
|
docType DocType
|
||||||
project string
|
project string
|
||||||
expected map[string]interface{}
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty_filters",
|
name: "empty_filters",
|
||||||
@@ -474,9 +474,9 @@ func TestCopyMetadataMulti(t *testing.T) {
|
|||||||
func TestJoinStrings(t *testing.T) {
|
func TestJoinStrings(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
strs []string
|
|
||||||
sep string
|
sep string
|
||||||
expected string
|
expected string
|
||||||
|
strs []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty_slice",
|
name: "empty_slice",
|
||||||
@@ -522,8 +522,8 @@ func TestTruncateString(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "shorter_than_max",
|
name: "shorter_than_max",
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sync provides synchronization between SQLite data and vector embeddings.
|
// Sync provides synchronization between SQLite data and vector embeddings.
|
||||||
type Sync struct {
|
type Sync struct {
|
||||||
client *Client
|
client *Client
|
||||||
|
chunkingManager *chunking.Manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSync creates a new sync service.
|
// NewSync creates a new sync service.
|
||||||
@@ -19,9 +21,23 @@ func NewSync(client *Client) *Sync {
|
|||||||
return &Sync{client: client}
|
return &Sync{client: client}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetChunkingManager sets the code chunking manager (optional).
|
||||||
|
// If set, observations will include code chunks from tracked files.
|
||||||
|
func (s *Sync) SetChunkingManager(manager *chunking.Manager) {
|
||||||
|
s.chunkingManager = manager
|
||||||
|
}
|
||||||
|
|
||||||
// SyncObservation syncs a single observation to the vector store.
|
// SyncObservation syncs a single observation to the vector store.
|
||||||
|
// If a chunking manager is configured, also chunks tracked code files.
|
||||||
func (s *Sync) SyncObservation(ctx context.Context, obs *models.Observation) error {
|
func (s *Sync) SyncObservation(ctx context.Context, obs *models.Observation) error {
|
||||||
docs := s.formatObservationDocs(obs)
|
docs := s.formatObservationDocs(obs)
|
||||||
|
|
||||||
|
// Add code chunks from tracked files if chunking manager is available
|
||||||
|
if s.chunkingManager != nil {
|
||||||
|
codeChunkDocs := s.formatCodeChunkDocs(ctx, obs)
|
||||||
|
docs = append(docs, codeChunkDocs...)
|
||||||
|
}
|
||||||
|
|
||||||
if len(docs) == 0 {
|
if len(docs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -99,6 +115,98 @@ func (s *Sync) formatObservationDocs(obs *models.Observation) []Document {
|
|||||||
return docs
|
return docs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatCodeChunkDocs formats code chunks from tracked files into vector documents.
|
||||||
|
// Uses AST-aware chunking to extract semantic code units (functions, classes, methods).
|
||||||
|
func (s *Sync) formatCodeChunkDocs(ctx context.Context, obs *models.Observation) []Document {
|
||||||
|
if s.chunkingManager == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine scope for metadata
|
||||||
|
scope := string(obs.Scope)
|
||||||
|
if scope == "" {
|
||||||
|
scope = "project"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all tracked files (read + modified)
|
||||||
|
allFiles := make([]string, 0, len(obs.FilesRead)+len(obs.FilesModified))
|
||||||
|
allFiles = append(allFiles, obs.FilesRead...)
|
||||||
|
allFiles = append(allFiles, obs.FilesModified...)
|
||||||
|
|
||||||
|
// Filter to only files supported by chunking manager
|
||||||
|
var supportedFiles []string
|
||||||
|
for _, file := range allFiles {
|
||||||
|
if s.chunkingManager.SupportsFile(file) {
|
||||||
|
supportedFiles = append(supportedFiles, file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(supportedFiles) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chunk all supported files
|
||||||
|
results, errs := s.chunkingManager.ChunkFiles(ctx, supportedFiles)
|
||||||
|
if len(errs) > 0 {
|
||||||
|
// Log errors but don't fail the entire sync
|
||||||
|
for _, err := range errs {
|
||||||
|
log.Warn().Err(err).Msg("Failed to chunk file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert chunks to vector documents
|
||||||
|
docs := make([]Document, 0)
|
||||||
|
chunkIndex := 0
|
||||||
|
|
||||||
|
for filePath, chunks := range results {
|
||||||
|
for _, chunk := range chunks {
|
||||||
|
doc := Document{
|
||||||
|
ID: fmt.Sprintf("obs_%d_chunk_%d", obs.ID, chunkIndex),
|
||||||
|
Content: chunk.SearchableContent(),
|
||||||
|
Metadata: map[string]any{
|
||||||
|
"sqlite_id": obs.ID,
|
||||||
|
"doc_type": string(DocTypeCodeChunk),
|
||||||
|
"field_type": "code_chunk",
|
||||||
|
"sdk_session_id": obs.SDKSessionID,
|
||||||
|
"project": obs.Project,
|
||||||
|
"scope": scope,
|
||||||
|
"created_at_epoch": obs.CreatedAtEpoch,
|
||||||
|
// Code chunk specific metadata
|
||||||
|
"file_path": filePath,
|
||||||
|
"language": string(chunk.Language),
|
||||||
|
"chunk_type": string(chunk.Type),
|
||||||
|
"symbol_name": chunk.Name,
|
||||||
|
"start_line": chunk.StartLine,
|
||||||
|
"end_line": chunk.EndLine,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add parent name if this is a method
|
||||||
|
if chunk.ParentName != "" {
|
||||||
|
doc.Metadata["parent_name"] = chunk.ParentName
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add signature if available
|
||||||
|
if chunk.Signature != "" {
|
||||||
|
doc.Metadata["signature"] = chunk.Signature
|
||||||
|
}
|
||||||
|
|
||||||
|
docs = append(docs, doc)
|
||||||
|
chunkIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(docs) > 0 {
|
||||||
|
log.Debug().
|
||||||
|
Int64("observationId", obs.ID).
|
||||||
|
Int("codeChunks", len(docs)).
|
||||||
|
Int("files", len(results)).
|
||||||
|
Msg("Generated code chunk documents")
|
||||||
|
}
|
||||||
|
|
||||||
|
return docs
|
||||||
|
}
|
||||||
|
|
||||||
// SyncSummary syncs a single session summary to the vector store.
|
// SyncSummary syncs a single session summary to the vector store.
|
||||||
func (s *Sync) SyncSummary(ctx context.Context, summary *models.SessionSummary) error {
|
func (s *Sync) SyncSummary(ctx context.Context, summary *models.SessionSummary) error {
|
||||||
docs := s.formatSummaryDocs(summary)
|
docs := s.formatSummaryDocs(summary)
|
||||||
@@ -191,21 +299,27 @@ func (s *Sync) SyncUserPrompt(ctx context.Context, prompt *models.UserPromptWith
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteObservations removes observation documents from the vector store.
|
// DeleteObservations removes observation documents from the vector store.
|
||||||
|
// Includes both observation fields (narrative, facts) and code chunks.
|
||||||
func (s *Sync) DeleteObservations(ctx context.Context, observationIDs []int64) error {
|
func (s *Sync) DeleteObservations(ctx context.Context, observationIDs []int64) error {
|
||||||
if len(observationIDs) == 0 {
|
if len(observationIDs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate all possible document IDs for these observations
|
// Generate all possible document IDs for these observations
|
||||||
// Pattern: obs_{id}_narrative, obs_{id}_fact_{0..n}
|
// Pattern: obs_{id}_narrative, obs_{id}_fact_{0..n}, obs_{id}_chunk_{0..n}
|
||||||
const maxFactsPerObs = 20
|
const maxFactsPerObs = 20
|
||||||
ids := make([]string, 0, len(observationIDs)*(maxFactsPerObs+1))
|
const maxChunksPerObs = 100 // Reasonable upper bound for code chunks
|
||||||
|
ids := make([]string, 0, len(observationIDs)*(maxFactsPerObs+maxChunksPerObs+1))
|
||||||
|
|
||||||
for _, obsID := range observationIDs {
|
for _, obsID := range observationIDs {
|
||||||
ids = append(ids, fmt.Sprintf("obs_%d_narrative", obsID))
|
ids = append(ids, fmt.Sprintf("obs_%d_narrative", obsID))
|
||||||
for i := 0; i < maxFactsPerObs; i++ {
|
for i := 0; i < maxFactsPerObs; i++ {
|
||||||
ids = append(ids, fmt.Sprintf("obs_%d_fact_%d", obsID, i))
|
ids = append(ids, fmt.Sprintf("obs_%d_fact_%d", obsID, i))
|
||||||
}
|
}
|
||||||
|
// Include code chunk IDs
|
||||||
|
for i := 0; i < maxChunksPerObs; i++ {
|
||||||
|
ids = append(ids, fmt.Sprintf("obs_%d_chunk_%d", obsID, i))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.DeleteDocuments(ctx, ids); err != nil {
|
if err := s.client.DeleteDocuments(ctx, ids); err != nil {
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ import (
|
|||||||
// Watcher monitors a file or directory for deletion and calls onDelete when removed.
|
// Watcher monitors a file or directory for deletion and calls onDelete when removed.
|
||||||
// It watches the parent directory since fsnotify cannot watch non-existent files.
|
// It watches the parent directory since fsnotify cannot watch non-existent files.
|
||||||
type Watcher struct {
|
type Watcher struct {
|
||||||
targetPath string // The file/directory to watch for deletion
|
|
||||||
parentPath string // Parent directory (what we actually watch)
|
|
||||||
onDelete func() // Callback when target is deleted
|
|
||||||
watcher *fsnotify.Watcher
|
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
|
onDelete func()
|
||||||
|
watcher *fsnotify.Watcher
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
targetPath string
|
||||||
|
parentPath string
|
||||||
|
debounce time.Duration
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
running bool
|
running bool
|
||||||
debounce time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Watcher for the given target path.
|
// New creates a new Watcher for the given target path.
|
||||||
|
|||||||
@@ -158,10 +158,10 @@ type SessionInitRequest struct {
|
|||||||
|
|
||||||
// SessionInitResponse is the response for session initialization.
|
// SessionInitResponse is the response for session initialization.
|
||||||
type SessionInitResponse struct {
|
type SessionInitResponse struct {
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
SessionDBID int64 `json:"sessionDbId"`
|
SessionDBID int64 `json:"sessionDbId"`
|
||||||
PromptNumber int `json:"promptNumber"`
|
PromptNumber int `json:"promptNumber"`
|
||||||
Skipped bool `json:"skipped,omitempty"`
|
Skipped bool `json:"skipped,omitempty"`
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
|
// DuplicatePromptWindowSeconds is the time window for detecting duplicate prompt submissions.
|
||||||
@@ -296,8 +296,8 @@ func (s *Service) handleSessionStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req SessionStartRequest
|
var req SessionStartRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, decodeErr.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func (s *Service) handleGetRelationGraph(w http.ResponseWriter, r *http.Request)
|
|||||||
// Get depth parameter (default 2)
|
// Get depth parameter (default 2)
|
||||||
depth := 2
|
depth := 2
|
||||||
if depthStr := r.URL.Query().Get("depth"); depthStr != "" {
|
if depthStr := r.URL.Query().Get("depth"); depthStr != "" {
|
||||||
if d, err := strconv.Atoi(depthStr); err == nil && d > 0 && d <= 5 {
|
if d, atoiErr := strconv.Atoi(depthStr); atoiErr == nil && d > 0 && d <= 5 {
|
||||||
depth = d
|
depth = d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ func (s *Service) handleGetRelatedObservations(w http.ResponseWriter, r *http.Re
|
|||||||
// Get minimum confidence parameter (default 0.4)
|
// Get minimum confidence parameter (default 0.4)
|
||||||
minConfidence := 0.4
|
minConfidence := 0.4
|
||||||
if confStr := r.URL.Query().Get("min_confidence"); confStr != "" {
|
if confStr := r.URL.Query().Get("min_confidence"); confStr != "" {
|
||||||
if c, err := strconv.ParseFloat(confStr, 64); err == nil && c >= 0 && c <= 1 {
|
if c, parseErr := strconv.ParseFloat(confStr, 64); parseErr == nil && c >= 0 && c <= 1 {
|
||||||
minConfidence = c
|
minConfidence = c
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
"github.com/lukaszraczylo/claude-mnemonic/pkg/models"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FeedbackRequest represents a user feedback submission.
|
// FeedbackRequest represents a user feedback submission.
|
||||||
@@ -65,9 +66,10 @@ func (s *Service) handleObservationFeedback(w http.ResponseWriter, r *http.Reque
|
|||||||
if err == nil && obs != nil {
|
if err == nil && obs != nil {
|
||||||
obs.UserFeedback = req.Feedback // Apply the new feedback
|
obs.UserFeedback = req.Feedback // Apply the new feedback
|
||||||
newScore = scoreCalculator.Calculate(obs, time.Now())
|
newScore = scoreCalculator.Calculate(obs, time.Now())
|
||||||
if err := observationStore.UpdateImportanceScore(r.Context(), id, newScore); err != nil {
|
if scoreErr := observationStore.UpdateImportanceScore(r.Context(), id, newScore); scoreErr != nil {
|
||||||
// Log but don't fail - feedback was recorded
|
// Log but don't fail - feedback was recorded
|
||||||
// Score will be updated on next recalculation cycle
|
// Score will be updated on next recalculation cycle
|
||||||
|
log.Warn().Err(scoreErr).Int64("id", id).Msg("Failed to update importance score after feedback")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,8 +263,9 @@ func (s *Service) handleUpdateConceptWeight(w http.ResponseWriter, r *http.Reque
|
|||||||
|
|
||||||
// Refresh concept weights in recalculator
|
// Refresh concept weights in recalculator
|
||||||
if recalculator != nil {
|
if recalculator != nil {
|
||||||
if err := recalculator.RefreshConceptWeights(r.Context()); err != nil {
|
if refreshErr := recalculator.RefreshConceptWeights(r.Context()); refreshErr != nil {
|
||||||
// Log but don't fail - weight was saved
|
// Log but don't fail - weight was saved
|
||||||
|
log.Warn().Err(refreshErr).Str("concept", concept).Msg("Failed to refresh concept weights in recalculator")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,8 +311,9 @@ func (s *Service) handleTriggerRecalculation(w http.ResponseWriter, r *http.Requ
|
|||||||
|
|
||||||
// Run recalculation in background
|
// Run recalculation in background
|
||||||
go func() {
|
go func() {
|
||||||
if err := recalculator.RecalculateNow(r.Context()); err != nil {
|
if recalcErr := recalculator.RecalculateNow(r.Context()); recalcErr != nil {
|
||||||
// Log error but don't block response
|
// Log error but don't block response
|
||||||
|
log.Warn().Err(recalcErr).Msg("Failed to trigger score recalculation")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -347,8 +351,9 @@ func (s *Service) incrementRetrievalCounts(ids []int64) {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := store.IncrementRetrievalCount(ctx, ids); err != nil {
|
if incrErr := store.IncrementRetrievalCount(ctx, ids); incrErr != nil {
|
||||||
// Log but don't fail - this is a background operation
|
// Log but don't fail - this is a background operation
|
||||||
|
log.Warn().Err(incrErr).Msg("Failed to increment retrieval count in background")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1276,8 +1276,6 @@ func TestObservationRequest_Fields(t *testing.T) {
|
|||||||
ClaudeSessionID: "session-abc",
|
ClaudeSessionID: "session-abc",
|
||||||
Project: "my-project",
|
Project: "my-project",
|
||||||
ToolName: "Read",
|
ToolName: "Read",
|
||||||
ToolInput: map[string]string{"path": "/file.go"},
|
|
||||||
ToolResponse: "file contents",
|
|
||||||
CWD: "/home/user/project",
|
CWD: "/home/user/project",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ func TestParseObservations_TableDriven(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
expectedCount int
|
|
||||||
expectedType models.ObservationType
|
expectedType models.ObservationType
|
||||||
expectedTitle string
|
expectedTitle string
|
||||||
checkConcepts []string
|
checkConcepts []string
|
||||||
|
expectedCount int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "valid_bugfix_observation",
|
name: "valid_bugfix_observation",
|
||||||
@@ -300,9 +300,9 @@ func TestParseSummary_TableDriven(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
|
expectedRequest string
|
||||||
sessionID int64
|
sessionID int64
|
||||||
expectNil bool
|
expectNil bool
|
||||||
expectedRequest string
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty_input",
|
name: "empty_input",
|
||||||
|
|||||||
@@ -31,15 +31,14 @@ type SyncSummaryFunc func(summary *models.SessionSummary)
|
|||||||
|
|
||||||
// Processor handles SDK agent processing of observations and summaries using Claude Code CLI.
|
// Processor handles SDK agent processing of observations and summaries using Claude Code CLI.
|
||||||
type Processor struct {
|
type Processor struct {
|
||||||
claudePath string
|
|
||||||
model string
|
|
||||||
observationStore *sqlite.ObservationStore
|
observationStore *sqlite.ObservationStore
|
||||||
summaryStore *sqlite.SummaryStore
|
summaryStore *sqlite.SummaryStore
|
||||||
broadcastFunc BroadcastFunc
|
broadcastFunc BroadcastFunc
|
||||||
syncObservationFunc SyncObservationFunc
|
syncObservationFunc SyncObservationFunc
|
||||||
syncSummaryFunc SyncSummaryFunc
|
syncSummaryFunc SyncSummaryFunc
|
||||||
// Semaphore to limit concurrent Claude CLI calls (prevents API overload)
|
sem chan struct{}
|
||||||
sem chan struct{}
|
claudePath string
|
||||||
|
model string
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetBroadcastFunc sets the broadcast callback for SSE events.
|
// SetBroadcastFunc sets the broadcast callback for SSE events.
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
|
|
||||||
func TestIsSelfReferentialSummary(t *testing.T) {
|
func TestIsSelfReferentialSummary(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
summary *models.ParsedSummary
|
summary *models.ParsedSummary
|
||||||
|
name string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -281,8 +281,8 @@ func TestTruncateForLog(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "shorter_than_max",
|
name: "shorter_than_max",
|
||||||
@@ -371,11 +371,11 @@ func TestCaptureFileMtimes(t *testing.T) {
|
|||||||
file1 := filepath.Join(tmpDir, "file1.txt")
|
file1 := filepath.Join(tmpDir, "file1.txt")
|
||||||
file2 := filepath.Join(tmpDir, "file2.txt")
|
file2 := filepath.Join(tmpDir, "file2.txt")
|
||||||
|
|
||||||
err = os.WriteFile(file1, []byte("content1"), 0644)
|
err = os.WriteFile(file1, []byte("content1"), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
err = os.WriteFile(file2, []byte("content2"), 0644)
|
err = os.WriteFile(file2, []byte("content2"), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -415,7 +415,7 @@ func TestGetFileMtimes(t *testing.T) {
|
|||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
err = os.WriteFile(testFile, []byte("content"), 0644)
|
err = os.WriteFile(testFile, []byte("content"), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -437,7 +437,7 @@ func TestGetFileContent(t *testing.T) {
|
|||||||
t.Run("reads_existing_file", func(t *testing.T) {
|
t.Run("reads_existing_file", func(t *testing.T) {
|
||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
content := "test content"
|
content := "test content"
|
||||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
err := os.WriteFile(testFile, []byte(content), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -459,7 +459,7 @@ func TestGetFileContent(t *testing.T) {
|
|||||||
for i := 0; i < 3000; i++ {
|
for i := 0; i < 3000; i++ {
|
||||||
longContent += "x"
|
longContent += "x"
|
||||||
}
|
}
|
||||||
err := os.WriteFile(testFile, []byte(longContent), 0644)
|
err := os.WriteFile(testFile, []byte(longContent), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -473,7 +473,7 @@ func TestGetFileContent(t *testing.T) {
|
|||||||
t.Run("resolves_relative_path_with_cwd", func(t *testing.T) {
|
t.Run("resolves_relative_path_with_cwd", func(t *testing.T) {
|
||||||
testFile := filepath.Join(tmpDir, "relative.txt")
|
testFile := filepath.Join(tmpDir, "relative.txt")
|
||||||
content := "relative content"
|
content := "relative content"
|
||||||
err := os.WriteFile(testFile, []byte(content), 0644)
|
err := os.WriteFile(testFile, []byte(content), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -719,8 +719,8 @@ func TestShouldSkipTrivialOperation_EdgeCases(t *testing.T) {
|
|||||||
// TestIsSelfReferentialSummary_MoreCases tests additional self-referential detection cases.
|
// TestIsSelfReferentialSummary_MoreCases tests additional self-referential detection cases.
|
||||||
func TestIsSelfReferentialSummary_MoreCases(t *testing.T) {
|
func TestIsSelfReferentialSummary_MoreCases(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
summary *models.ParsedSummary
|
summary *models.ParsedSummary
|
||||||
|
name string
|
||||||
expected bool
|
expected bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -932,7 +932,7 @@ func TestCaptureFileMtimes_DuplicatePaths(t *testing.T) {
|
|||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
testFile := filepath.Join(tmpDir, "shared.txt")
|
testFile := filepath.Join(tmpDir, "shared.txt")
|
||||||
err = os.WriteFile(testFile, []byte("content"), 0644)
|
err = os.WriteFile(testFile, []byte("content"), 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,12 +24,12 @@ var ObservationConcepts = []string{
|
|||||||
|
|
||||||
// ToolExecution represents a tool execution for observation.
|
// ToolExecution represents a tool execution for observation.
|
||||||
type ToolExecution struct {
|
type ToolExecution struct {
|
||||||
ID int64
|
|
||||||
ToolName string
|
ToolName string
|
||||||
ToolInput string
|
ToolInput string
|
||||||
ToolOutput string
|
ToolOutput string
|
||||||
CreatedAtEpoch int64
|
|
||||||
CWD string
|
CWD string
|
||||||
|
ID int64
|
||||||
|
CreatedAtEpoch int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildObservationPrompt builds a prompt for processing a tool observation.
|
// BuildObservationPrompt builds a prompt for processing a tool observation.
|
||||||
@@ -67,12 +67,12 @@ func BuildObservationPrompt(exec ToolExecution) string {
|
|||||||
|
|
||||||
// SummaryRequest contains data for building a summary prompt.
|
// SummaryRequest contains data for building a summary prompt.
|
||||||
type SummaryRequest struct {
|
type SummaryRequest struct {
|
||||||
SessionDBID int64
|
|
||||||
SDKSessionID string
|
SDKSessionID string
|
||||||
Project string
|
Project string
|
||||||
UserPrompt string
|
UserPrompt string
|
||||||
LastUserMessage string
|
LastUserMessage string
|
||||||
LastAssistantMessage string
|
LastAssistantMessage string
|
||||||
|
SessionDBID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildSummaryPrompt builds a prompt requesting a session summary.
|
// BuildSummaryPrompt builds a prompt requesting a session summary.
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ func TestTruncate(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
maxLen int
|
|
||||||
expected string
|
expected string
|
||||||
|
maxLen int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "shorter_than_max",
|
name: "shorter_than_max",
|
||||||
@@ -58,10 +58,10 @@ func TestTruncate(t *testing.T) {
|
|||||||
func TestBuildObservationPrompt(t *testing.T) {
|
func TestBuildObservationPrompt(t *testing.T) {
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct { //nolint:govet
|
||||||
name string
|
|
||||||
exec ToolExecution
|
exec ToolExecution
|
||||||
contains []string
|
contains []string
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic_read_tool",
|
name: "basic_read_tool",
|
||||||
|
|||||||
+78
-82
@@ -12,6 +12,10 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking/golang"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking/python"
|
||||||
|
"github.com/lukaszraczylo/claude-mnemonic/internal/chunking/typescript"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/config"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/db/sqlite"
|
||||||
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
"github.com/lukaszraczylo/claude-mnemonic/internal/embedding"
|
||||||
@@ -56,80 +60,49 @@ type RetrievalStats struct {
|
|||||||
|
|
||||||
// Service is the main worker service orchestrator.
|
// Service is the main worker service orchestrator.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
// Version of the worker binary
|
startTime time.Time
|
||||||
version string
|
initError error
|
||||||
|
ctx context.Context
|
||||||
// Configuration
|
queryExpander *expansion.Expander
|
||||||
config *config.Config
|
recalculator *scoring.Recalculator
|
||||||
|
|
||||||
// Database
|
|
||||||
store *sqlite.Store
|
|
||||||
sessionStore *sqlite.SessionStore
|
|
||||||
observationStore *sqlite.ObservationStore
|
|
||||||
summaryStore *sqlite.SummaryStore
|
summaryStore *sqlite.SummaryStore
|
||||||
promptStore *sqlite.PromptStore
|
promptStore *sqlite.PromptStore
|
||||||
conflictStore *sqlite.ConflictStore
|
conflictStore *sqlite.ConflictStore
|
||||||
patternStore *sqlite.PatternStore
|
patternStore *sqlite.PatternStore
|
||||||
relationStore *sqlite.RelationStore
|
relationStore *sqlite.RelationStore
|
||||||
|
patternDetector *pattern.Detector
|
||||||
// Pattern detection
|
sessionManager *session.Manager
|
||||||
patternDetector *pattern.Detector
|
sseBroadcaster *sse.Broadcaster
|
||||||
|
router *chi.Mux
|
||||||
// Domain services
|
embedSvc *embedding.Service
|
||||||
sessionManager *session.Manager
|
vectorClient *sqlitevec.Client
|
||||||
sseBroadcaster *sse.Broadcaster
|
vectorSync *sqlitevec.Sync
|
||||||
processor *sdk.Processor
|
reranker *reranking.Service
|
||||||
|
updater *update.Updater
|
||||||
// Vector database (sqlite-vec with local embeddings)
|
observationStore *sqlite.ObservationStore
|
||||||
embedSvc *embedding.Service
|
scoreCalculator *scoring.Calculator
|
||||||
vectorClient *sqlitevec.Client
|
processor *sdk.Processor
|
||||||
vectorSync *sqlitevec.Sync
|
server *http.Server
|
||||||
|
sessionStore *sqlite.SessionStore
|
||||||
// Cross-encoder reranking (for improved search relevance)
|
|
||||||
reranker *reranking.Service
|
|
||||||
|
|
||||||
// Query expansion (for improved search recall)
|
|
||||||
queryExpander *expansion.Expander
|
|
||||||
|
|
||||||
// Importance scoring
|
|
||||||
scoreCalculator *scoring.Calculator
|
|
||||||
recalculator *scoring.Recalculator
|
|
||||||
|
|
||||||
// HTTP server
|
|
||||||
router *chi.Mux
|
|
||||||
server *http.Server
|
|
||||||
startTime time.Time
|
|
||||||
|
|
||||||
// Retrieval statistics (per-project)
|
|
||||||
retrievalStats map[string]*RetrievalStats
|
retrievalStats map[string]*RetrievalStats
|
||||||
|
configWatcher *watcher.Watcher
|
||||||
|
store *sqlite.Store
|
||||||
|
cancel context.CancelFunc
|
||||||
|
dbWatcher *watcher.Watcher
|
||||||
|
staleQueue chan staleVerifyRequest
|
||||||
|
config *config.Config
|
||||||
|
version string
|
||||||
|
wg sync.WaitGroup
|
||||||
|
initMu sync.RWMutex
|
||||||
retrievalStatsMu sync.RWMutex
|
retrievalStatsMu sync.RWMutex
|
||||||
|
staleQueueOnce sync.Once
|
||||||
// Lifecycle
|
ready atomic.Bool
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
wg sync.WaitGroup
|
|
||||||
|
|
||||||
// Initialization state (for deferred init)
|
|
||||||
ready atomic.Bool
|
|
||||||
initError error
|
|
||||||
initMu sync.RWMutex
|
|
||||||
|
|
||||||
// Background verification queue for stale observations
|
|
||||||
staleQueue chan staleVerifyRequest
|
|
||||||
staleQueueOnce sync.Once
|
|
||||||
|
|
||||||
// File watchers for auto-recreation on deletion
|
|
||||||
dbWatcher *watcher.Watcher
|
|
||||||
configWatcher *watcher.Watcher
|
|
||||||
|
|
||||||
// Self-updater
|
|
||||||
updater *update.Updater
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// staleVerifyRequest represents a request to verify a stale observation in background
|
// staleVerifyRequest represents a request to verify a stale observation in background
|
||||||
type staleVerifyRequest struct {
|
type staleVerifyRequest struct {
|
||||||
observationID int64
|
|
||||||
cwd string
|
cwd string
|
||||||
|
observationID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new worker service with deferred initialization.
|
// NewService creates a new worker service with deferred initialization.
|
||||||
@@ -223,17 +196,29 @@ func (s *Service) initializeAsync() {
|
|||||||
} else {
|
} else {
|
||||||
embedSvc = emb
|
embedSvc = emb
|
||||||
// Create sqlite-vec client using the same DB connection
|
// Create sqlite-vec client using the same DB connection
|
||||||
client, err := sqlitevec.NewClient(sqlitevec.Config{
|
client, clientErr := sqlitevec.NewClient(sqlitevec.Config{
|
||||||
DB: store.DB(),
|
DB: store.DB(),
|
||||||
}, embedSvc)
|
}, embedSvc)
|
||||||
if err != nil {
|
if clientErr != nil {
|
||||||
log.Warn().Err(err).Msg("sqlite-vec client creation failed - vector search disabled")
|
log.Warn().Err(clientErr).Msg("sqlite-vec client creation failed - vector search disabled")
|
||||||
} else {
|
} else {
|
||||||
vectorClient = client
|
vectorClient = client
|
||||||
vectorSync = sqlitevec.NewSync(client)
|
vectorSync = sqlitevec.NewSync(client)
|
||||||
|
|
||||||
|
// Initialize AST-aware code chunking
|
||||||
|
chunkOpts := chunking.DefaultChunkOptions()
|
||||||
|
chunkers := []chunking.Chunker{
|
||||||
|
golang.NewChunker(chunkOpts),
|
||||||
|
python.NewChunker(chunkOpts),
|
||||||
|
typescript.NewChunker(chunkOpts),
|
||||||
|
}
|
||||||
|
chunkingManager := chunking.NewManager(chunkers, chunkOpts)
|
||||||
|
vectorSync.SetChunkingManager(chunkingManager)
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Str("model", embedSvc.Version()).
|
Str("model", embedSvc.Version()).
|
||||||
Msg("sqlite-vec vector search enabled")
|
Strs("chunkers", []string{"go", "python", "typescript"}).
|
||||||
|
Msg("sqlite-vec vector search with AST-aware code chunking enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cross-encoder reranking service if enabled
|
// Create cross-encoder reranking service if enabled
|
||||||
@@ -243,9 +228,9 @@ func (s *Service) initializeAsync() {
|
|||||||
rerankCfg.Alpha = s.config.RerankingAlpha
|
rerankCfg.Alpha = s.config.RerankingAlpha
|
||||||
}
|
}
|
||||||
|
|
||||||
ranker, err := reranking.NewService(rerankCfg)
|
ranker, rankerErr := reranking.NewService(rerankCfg)
|
||||||
if err != nil {
|
if rankerErr != nil {
|
||||||
log.Warn().Err(err).Msg("Cross-encoder reranking service creation failed - reranking disabled")
|
log.Warn().Err(rankerErr).Msg("Cross-encoder reranking service creation failed - reranking disabled")
|
||||||
} else {
|
} else {
|
||||||
reranker = ranker
|
reranker = ranker
|
||||||
log.Info().
|
log.Info().
|
||||||
@@ -457,8 +442,8 @@ func (s *Service) startWatchers() {
|
|||||||
log.Warn().Err(err).Msg("Failed to create database watcher")
|
log.Warn().Err(err).Msg("Failed to create database watcher")
|
||||||
} else {
|
} else {
|
||||||
s.dbWatcher = dbWatcher
|
s.dbWatcher = dbWatcher
|
||||||
if err := dbWatcher.Start(); err != nil {
|
if startErr := dbWatcher.Start(); startErr != nil {
|
||||||
log.Warn().Err(err).Msg("Failed to start database watcher")
|
log.Warn().Err(startErr).Msg("Failed to start database watcher")
|
||||||
} else {
|
} else {
|
||||||
log.Info().Str("path", s.config.DBPath).Msg("Database file watcher started")
|
log.Info().Str("path", s.config.DBPath).Msg("Database file watcher started")
|
||||||
}
|
}
|
||||||
@@ -559,15 +544,26 @@ func (s *Service) reinitializeDatabase() {
|
|||||||
log.Warn().Err(err).Msg("Embedding service creation failed after reinit")
|
log.Warn().Err(err).Msg("Embedding service creation failed after reinit")
|
||||||
} else {
|
} else {
|
||||||
embedSvc = emb
|
embedSvc = emb
|
||||||
client, err := sqlitevec.NewClient(sqlitevec.Config{
|
client, clientErr := sqlitevec.NewClient(sqlitevec.Config{
|
||||||
DB: store.DB(),
|
DB: store.DB(),
|
||||||
}, embedSvc)
|
}, embedSvc)
|
||||||
if err != nil {
|
if clientErr != nil {
|
||||||
log.Warn().Err(err).Msg("sqlite-vec client creation failed after reinit")
|
log.Warn().Err(clientErr).Msg("sqlite-vec client creation failed after reinit")
|
||||||
} else {
|
} else {
|
||||||
vectorClient = client
|
vectorClient = client
|
||||||
vectorSync = sqlitevec.NewSync(client)
|
vectorSync = sqlitevec.NewSync(client)
|
||||||
log.Info().Msg("sqlite-vec reconnected after reinit")
|
|
||||||
|
// Initialize AST-aware code chunking
|
||||||
|
chunkOpts := chunking.DefaultChunkOptions()
|
||||||
|
chunkers := []chunking.Chunker{
|
||||||
|
golang.NewChunker(chunkOpts),
|
||||||
|
python.NewChunker(chunkOpts),
|
||||||
|
typescript.NewChunker(chunkOpts),
|
||||||
|
}
|
||||||
|
chunkingManager := chunking.NewManager(chunkers, chunkOpts)
|
||||||
|
vectorSync.SetChunkingManager(chunkingManager)
|
||||||
|
|
||||||
|
log.Info().Msg("sqlite-vec with code chunking reconnected after reinit")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recreate cross-encoder reranking service if enabled
|
// Recreate cross-encoder reranking service if enabled
|
||||||
@@ -577,9 +573,9 @@ func (s *Service) reinitializeDatabase() {
|
|||||||
rerankCfg.Alpha = s.config.RerankingAlpha
|
rerankCfg.Alpha = s.config.RerankingAlpha
|
||||||
}
|
}
|
||||||
|
|
||||||
ranker, err := reranking.NewService(rerankCfg)
|
ranker, rankerErr := reranking.NewService(rerankCfg)
|
||||||
if err != nil {
|
if rankerErr != nil {
|
||||||
log.Warn().Err(err).Msg("Cross-encoder reranking service creation failed after reinit")
|
log.Warn().Err(rankerErr).Msg("Cross-encoder reranking service creation failed after reinit")
|
||||||
} else {
|
} else {
|
||||||
reranker = ranker
|
reranker = ranker
|
||||||
log.Info().Msg("Cross-encoder reranking reconnected after reinit")
|
log.Info().Msg("Cross-encoder reranking reconnected after reinit")
|
||||||
@@ -824,8 +820,8 @@ func (s *Service) rebuildAllVectors(
|
|||||||
log.Error().Err(err).Msg("Failed to fetch observations for vector rebuild")
|
log.Error().Err(err).Msg("Failed to fetch observations for vector rebuild")
|
||||||
} else {
|
} else {
|
||||||
for _, obs := range observations {
|
for _, obs := range observations {
|
||||||
if err := vectorSync.SyncObservation(s.ctx, obs); err != nil {
|
if syncErr := vectorSync.SyncObservation(s.ctx, obs); syncErr != nil {
|
||||||
log.Warn().Err(err).Int64("id", obs.ID).Msg("Failed to sync observation during rebuild")
|
log.Warn().Err(syncErr).Int64("id", obs.ID).Msg("Failed to sync observation during rebuild")
|
||||||
syncErrors++
|
syncErrors++
|
||||||
} else {
|
} else {
|
||||||
totalSynced++
|
totalSynced++
|
||||||
@@ -840,8 +836,8 @@ func (s *Service) rebuildAllVectors(
|
|||||||
log.Error().Err(err).Msg("Failed to fetch summaries for vector rebuild")
|
log.Error().Err(err).Msg("Failed to fetch summaries for vector rebuild")
|
||||||
} else {
|
} else {
|
||||||
for _, summary := range summaries {
|
for _, summary := range summaries {
|
||||||
if err := vectorSync.SyncSummary(s.ctx, summary); err != nil {
|
if syncErr := vectorSync.SyncSummary(s.ctx, summary); syncErr != nil {
|
||||||
log.Warn().Err(err).Int64("id", summary.ID).Msg("Failed to sync summary during rebuild")
|
log.Warn().Err(syncErr).Int64("id", summary.ID).Msg("Failed to sync summary during rebuild")
|
||||||
syncErrors++
|
syncErrors++
|
||||||
} else {
|
} else {
|
||||||
totalSynced++
|
totalSynced++
|
||||||
|
|||||||
@@ -21,11 +21,11 @@ const (
|
|||||||
|
|
||||||
// ObservationData contains data for a tool observation.
|
// ObservationData contains data for a tool observation.
|
||||||
type ObservationData struct {
|
type ObservationData struct {
|
||||||
ToolName string
|
|
||||||
ToolInput interface{}
|
ToolInput interface{}
|
||||||
ToolResponse interface{}
|
ToolResponse interface{}
|
||||||
PromptNumber int
|
ToolName string
|
||||||
CWD string
|
CWD string
|
||||||
|
PromptNumber int
|
||||||
}
|
}
|
||||||
|
|
||||||
// SummarizeData contains data for a summarize request.
|
// SummarizeData contains data for a summarize request.
|
||||||
@@ -36,30 +36,28 @@ type SummarizeData struct {
|
|||||||
|
|
||||||
// PendingMessage represents a message queued for SDK processing.
|
// PendingMessage represents a message queued for SDK processing.
|
||||||
type PendingMessage struct {
|
type PendingMessage struct {
|
||||||
Type MessageType
|
|
||||||
Observation *ObservationData
|
Observation *ObservationData
|
||||||
Summarize *SummarizeData
|
Summarize *SummarizeData
|
||||||
|
Type MessageType
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveSession represents an in-memory active session being processed.
|
// ActiveSession represents an in-memory active session being processed.
|
||||||
type ActiveSession struct {
|
type ActiveSession struct {
|
||||||
SessionDBID int64
|
StartTime time.Time
|
||||||
ClaudeSessionID string
|
ctx context.Context
|
||||||
SDKSessionID string
|
cancel context.CancelFunc
|
||||||
|
notify chan struct{}
|
||||||
Project string
|
Project string
|
||||||
UserPrompt string
|
UserPrompt string
|
||||||
|
SDKSessionID string
|
||||||
|
ClaudeSessionID string
|
||||||
|
pendingMessages []PendingMessage
|
||||||
LastPromptNumber int
|
LastPromptNumber int
|
||||||
StartTime time.Time
|
|
||||||
CumulativeInputTokens int64
|
CumulativeInputTokens int64
|
||||||
CumulativeOutputTokens int64
|
CumulativeOutputTokens int64
|
||||||
|
SessionDBID int64
|
||||||
// Concurrency control
|
messageMu sync.Mutex
|
||||||
pendingMessages []PendingMessage
|
generatorActive atomic.Bool
|
||||||
messageMu sync.Mutex
|
|
||||||
notify chan struct{}
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
generatorActive atomic.Bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionTimeout is how long an inactive session can exist before cleanup.
|
// SessionTimeout is how long an inactive session can exist before cleanup.
|
||||||
@@ -70,15 +68,14 @@ const CleanupInterval = 5 * time.Minute
|
|||||||
|
|
||||||
// Manager manages active session lifecycles.
|
// Manager manages active session lifecycles.
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
sessionStore *sqlite.SessionStore
|
ctx context.Context
|
||||||
sessions map[int64]*ActiveSession
|
sessionStore *sqlite.SessionStore
|
||||||
mu sync.RWMutex
|
sessions map[int64]*ActiveSession
|
||||||
onCreated func(int64)
|
onCreated func(int64)
|
||||||
onDeleted func(int64)
|
onDeleted func(int64)
|
||||||
ctx context.Context
|
cancel context.CancelFunc
|
||||||
cancel context.CancelFunc
|
|
||||||
// Global notification channel for immediate processing
|
|
||||||
ProcessNotify chan struct{}
|
ProcessNotify chan struct{}
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a new session manager.
|
// NewManager creates a new session manager.
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ func (s *ManagerSuite) TestActiveSession() {
|
|||||||
SDKSessionID: "sdk-123",
|
SDKSessionID: "sdk-123",
|
||||||
Project: "test-project",
|
Project: "test-project",
|
||||||
UserPrompt: "Hello",
|
UserPrompt: "Hello",
|
||||||
StartTime: time.Now(),
|
StartTime: time.Now(), //nolint:govet
|
||||||
pendingMessages: make([]PendingMessage, 0),
|
pendingMessages: make([]PendingMessage, 0), //nolint:govet
|
||||||
notify: make(chan struct{}, 1),
|
notify: make(chan struct{}, 1), //nolint:govet
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Equal(int64(1), session.SessionDBID)
|
s.Equal(int64(1), session.SessionDBID)
|
||||||
@@ -134,7 +134,7 @@ func (s *ManagerSuite) TestDeleteSession() {
|
|||||||
session := &ActiveSession{
|
session := &ActiveSession{
|
||||||
SessionDBID: 1,
|
SessionDBID: 1,
|
||||||
Project: "test-project",
|
Project: "test-project",
|
||||||
StartTime: time.Now(),
|
StartTime: time.Now(), //nolint:govet
|
||||||
pendingMessages: []PendingMessage{},
|
pendingMessages: []PendingMessage{},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
@@ -236,8 +236,8 @@ func TestTimeoutConstants(t *testing.T) {
|
|||||||
func TestObservationData(t *testing.T) {
|
func TestObservationData(t *testing.T) {
|
||||||
data := ObservationData{
|
data := ObservationData{
|
||||||
ToolName: "Read",
|
ToolName: "Read",
|
||||||
ToolInput: map[string]string{"path": "/test/file.go"},
|
ToolInput: map[string]string{"path": "/test/file.go"}, //nolint:govet
|
||||||
ToolResponse: "file content",
|
ToolResponse: "file content", //nolint:govet
|
||||||
PromptNumber: 1,
|
PromptNumber: 1,
|
||||||
CWD: "/test",
|
CWD: "/test",
|
||||||
}
|
}
|
||||||
@@ -333,7 +333,7 @@ func TestConcurrentSessionAccess(t *testing.T) {
|
|||||||
// TestProcessNotifyChannel tests the process notification channel.
|
// TestProcessNotifyChannel tests the process notification channel.
|
||||||
func TestProcessNotifyChannel(t *testing.T) {
|
func TestProcessNotifyChannel(t *testing.T) {
|
||||||
manager := &Manager{
|
manager := &Manager{
|
||||||
sessions: make(map[int64]*ActiveSession),
|
sessions: make(map[int64]*ActiveSession), //nolint:govet
|
||||||
ProcessNotify: make(chan struct{}, 1),
|
ProcessNotify: make(chan struct{}, 1),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,7 +367,7 @@ func TestActiveSessionContext(t *testing.T) {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
session := &ActiveSession{
|
session := &ActiveSession{
|
||||||
SessionDBID: 1,
|
SessionDBID: 1, //nolint:govet
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
}
|
}
|
||||||
@@ -438,7 +438,7 @@ func (s *ManagerSuite) TestShutdownAll() {
|
|||||||
s.manager.sessions[i] = &ActiveSession{
|
s.manager.sessions[i] = &ActiveSession{
|
||||||
SessionDBID: i,
|
SessionDBID: i,
|
||||||
Project: "test-project",
|
Project: "test-project",
|
||||||
StartTime: time.Now(),
|
StartTime: time.Now(), //nolint:govet
|
||||||
pendingMessages: []PendingMessage{},
|
pendingMessages: []PendingMessage{},
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
@@ -479,7 +479,7 @@ func (s *ManagerSuite) TestDeleteNonExistentSession() {
|
|||||||
// TestLastPromptNumber tests prompt number tracking.
|
// TestLastPromptNumber tests prompt number tracking.
|
||||||
func TestLastPromptNumber(t *testing.T) {
|
func TestLastPromptNumber(t *testing.T) {
|
||||||
session := &ActiveSession{
|
session := &ActiveSession{
|
||||||
SessionDBID: 1,
|
SessionDBID: 1, //nolint:govet
|
||||||
LastPromptNumber: 0,
|
LastPromptNumber: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +526,7 @@ func TestActiveSessionNotifyChannel(t *testing.T) {
|
|||||||
// TestMessageMutex tests message mutex operations.
|
// TestMessageMutex tests message mutex operations.
|
||||||
func TestMessageMutex(t *testing.T) {
|
func TestMessageMutex(t *testing.T) {
|
||||||
session := &ActiveSession{
|
session := &ActiveSession{
|
||||||
pendingMessages: make([]PendingMessage, 0),
|
pendingMessages: make([]PendingMessage, 0), //nolint:govet
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
@@ -559,7 +559,7 @@ func (s *ManagerSuite) TestQueueDepthMultipleSessions() {
|
|||||||
}
|
}
|
||||||
s.manager.sessions[2] = &ActiveSession{
|
s.manager.sessions[2] = &ActiveSession{
|
||||||
SessionDBID: 2,
|
SessionDBID: 2,
|
||||||
pendingMessages: make([]PendingMessage, 0),
|
pendingMessages: make([]PendingMessage, 0), //nolint:govet
|
||||||
}
|
}
|
||||||
s.manager.sessions[3] = &ActiveSession{
|
s.manager.sessions[3] = &ActiveSession{
|
||||||
SessionDBID: 3,
|
SessionDBID: 3,
|
||||||
@@ -658,7 +658,7 @@ func TestActiveSessionCWD(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
data := ObservationData{
|
data := ObservationData{
|
||||||
ToolName: "Test",
|
ToolName: "Test", //nolint:govet
|
||||||
CWD: tt.cwd,
|
CWD: tt.cwd,
|
||||||
}
|
}
|
||||||
assert.Equal(t, tt.cwd, data.CWD)
|
assert.Equal(t, tt.cwd, data.CWD)
|
||||||
@@ -669,22 +669,22 @@ func TestActiveSessionCWD(t *testing.T) {
|
|||||||
// TestToolInputResponse tests various tool input/response types.
|
// TestToolInputResponse tests various tool input/response types.
|
||||||
func TestToolInputResponse(t *testing.T) {
|
func TestToolInputResponse(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
input interface{}
|
input interface{}
|
||||||
response interface{}
|
response interface{}
|
||||||
|
name string
|
||||||
}{
|
}{
|
||||||
{"nil_values", nil, nil},
|
{name: "nil_values", input: nil, response: nil},
|
||||||
{"string_values", "input string", "response string"},
|
{name: "string_values", input: "input string", response: "response string"},
|
||||||
{"map_values", map[string]string{"key": "value"}, map[string]interface{}{"result": true}},
|
{name: "map_values", input: map[string]string{"key": "value"}, response: map[string]interface{}{"result": true}},
|
||||||
{"slice_values", []string{"a", "b"}, []int{1, 2, 3}},
|
{name: "slice_values", input: []string{"a", "b"}, response: []int{1, 2, 3}},
|
||||||
{"int_values", 42, 100},
|
{name: "int_values", input: 42, response: 100},
|
||||||
{"bool_values", true, false},
|
{name: "bool_values", input: true, response: false},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
data := ObservationData{
|
data := ObservationData{
|
||||||
ToolName: "TestTool",
|
ToolName: "TestTool", //nolint:govet
|
||||||
ToolInput: tt.input,
|
ToolInput: tt.input,
|
||||||
ToolResponse: tt.response,
|
ToolResponse: tt.response,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ const (
|
|||||||
|
|
||||||
// Client represents a connected SSE client.
|
// Client represents a connected SSE client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
ID string
|
|
||||||
Writer http.ResponseWriter
|
Writer http.ResponseWriter
|
||||||
Flusher http.Flusher
|
Flusher http.Flusher
|
||||||
Done chan struct{}
|
Done chan struct{}
|
||||||
|
ID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcaster manages SSE client connections and message broadcasting.
|
// Broadcaster manages SSE client connections and message broadcasting.
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ func TestHandleSSE(t *testing.T) {
|
|||||||
// TestBroadcastJSON tests broadcasting various JSON types.
|
// TestBroadcastJSON tests broadcasting various JSON types.
|
||||||
func TestBroadcastJSON(t *testing.T) {
|
func TestBroadcastJSON(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
data interface{}
|
data interface{}
|
||||||
|
name string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,11 +62,11 @@ type BaseInput struct {
|
|||||||
// HookContext provides common context for hook handlers.
|
// HookContext provides common context for hook handlers.
|
||||||
type HookContext struct {
|
type HookContext struct {
|
||||||
HookName string
|
HookName string
|
||||||
Port int
|
|
||||||
Project string
|
Project string
|
||||||
SessionID string
|
SessionID string
|
||||||
CWD string
|
CWD string
|
||||||
RawInput []byte
|
RawInput []byte
|
||||||
|
Port int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HookHandler is a function that handles hook-specific logic.
|
// HookHandler is a function that handles hook-specific logic.
|
||||||
@@ -92,8 +92,8 @@ func RunHook[T any](hookName string, handler HookHandler[T]) {
|
|||||||
|
|
||||||
// Parse input
|
// Parse input
|
||||||
var input T
|
var input T
|
||||||
if err := json.Unmarshal(inputData, &input); err != nil {
|
if unmarshalErr := json.Unmarshal(inputData, &input); unmarshalErr != nil {
|
||||||
WriteError(hookName, err)
|
WriteError(hookName, unmarshalErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,11 +320,11 @@ func TestExtractBaseVersion(t *testing.T) {
|
|||||||
// TestPOST tests the POST function with a mock server.
|
// TestPOST tests the POST function with a mock server.
|
||||||
func TestPOST(t *testing.T) {
|
func TestPOST(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
serverHandler func(w http.ResponseWriter, r *http.Request)
|
|
||||||
body interface{}
|
body interface{}
|
||||||
expectError bool
|
serverHandler func(w http.ResponseWriter, r *http.Request)
|
||||||
expectedResult map[string]interface{}
|
expectedResult map[string]interface{}
|
||||||
|
name string
|
||||||
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "successful POST with JSON response",
|
name: "successful POST with JSON response",
|
||||||
@@ -393,10 +393,10 @@ func TestPOST(t *testing.T) {
|
|||||||
// TestGET tests the GET function with a mock server.
|
// TestGET tests the GET function with a mock server.
|
||||||
func TestGET(t *testing.T) {
|
func TestGET(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
serverHandler func(w http.ResponseWriter, r *http.Request)
|
serverHandler func(w http.ResponseWriter, r *http.Request)
|
||||||
expectError bool
|
|
||||||
expectedResult map[string]interface{}
|
expectedResult map[string]interface{}
|
||||||
|
name string
|
||||||
|
expectError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "successful GET with JSON response",
|
name: "successful GET with JSON response",
|
||||||
@@ -532,8 +532,8 @@ func TestExitCodes(t *testing.T) {
|
|||||||
func TestHookResponse(t *testing.T) {
|
func TestHookResponse(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
response HookResponse
|
|
||||||
expected string
|
expected string
|
||||||
|
response HookResponse
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "continue true",
|
name: "continue true",
|
||||||
@@ -597,8 +597,8 @@ func TestHookContext(t *testing.T) {
|
|||||||
// TestIsWorkerRunning_WithServer tests IsWorkerRunning with actual server.
|
// TestIsWorkerRunning_WithServer tests IsWorkerRunning with actual server.
|
||||||
func TestIsWorkerRunning_WithServer(t *testing.T) {
|
func TestIsWorkerRunning_WithServer(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
serverHandler func(w http.ResponseWriter, r *http.Request)
|
serverHandler func(w http.ResponseWriter, r *http.Request)
|
||||||
|
name string
|
||||||
expectedResult bool
|
expectedResult bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -828,8 +828,8 @@ func TestBaseInput_PartialFields(t *testing.T) {
|
|||||||
func TestHookResponse_Marshal(t *testing.T) {
|
func TestHookResponse_Marshal(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
response HookResponse
|
|
||||||
contains []string
|
contains []string
|
||||||
|
response HookResponse
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "continue true",
|
name: "continue true",
|
||||||
@@ -1177,7 +1177,7 @@ func TestHookContext_RawInput(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctx := HookContext{
|
ctx := HookContext{
|
||||||
HookName: "test",
|
HookName: "test", //nolint:govet
|
||||||
RawInput: tt.rawInput,
|
RawInput: tt.rawInput,
|
||||||
}
|
}
|
||||||
assert.Equal(t, tt.rawInput, ctx.RawInput)
|
assert.Equal(t, tt.rawInput, ctx.RawInput)
|
||||||
|
|||||||
@@ -33,25 +33,25 @@ const (
|
|||||||
|
|
||||||
// ObservationConflict tracks conflicting observations.
|
// ObservationConflict tracks conflicting observations.
|
||||||
type ObservationConflict struct {
|
type ObservationConflict struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
ResolvedAt *string `db:"resolved_at" json:"resolved_at,omitempty"`
|
||||||
NewerObsID int64 `db:"newer_obs_id" json:"newer_obs_id"`
|
|
||||||
OlderObsID int64 `db:"older_obs_id" json:"older_obs_id"`
|
|
||||||
ConflictType ConflictType `db:"conflict_type" json:"conflict_type"`
|
ConflictType ConflictType `db:"conflict_type" json:"conflict_type"`
|
||||||
Resolution ConflictResolution `db:"resolution" json:"resolution"`
|
Resolution ConflictResolution `db:"resolution" json:"resolution"`
|
||||||
Reason string `db:"reason" json:"reason"`
|
Reason string `db:"reason" json:"reason"`
|
||||||
DetectedAt string `db:"detected_at" json:"detected_at"`
|
DetectedAt string `db:"detected_at" json:"detected_at"`
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
NewerObsID int64 `db:"newer_obs_id" json:"newer_obs_id"`
|
||||||
|
OlderObsID int64 `db:"older_obs_id" json:"older_obs_id"`
|
||||||
DetectedAtEpoch int64 `db:"detected_at_epoch" json:"detected_at_epoch"`
|
DetectedAtEpoch int64 `db:"detected_at_epoch" json:"detected_at_epoch"`
|
||||||
Resolved bool `db:"resolved" json:"resolved"`
|
Resolved bool `db:"resolved" json:"resolved"`
|
||||||
ResolvedAt *string `db:"resolved_at" json:"resolved_at,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConflictDetectionResult contains the result of conflict detection.
|
// ConflictDetectionResult contains the result of conflict detection.
|
||||||
type ConflictDetectionResult struct {
|
type ConflictDetectionResult struct {
|
||||||
HasConflict bool
|
|
||||||
Type ConflictType
|
Type ConflictType
|
||||||
Resolution ConflictResolution
|
Resolution ConflictResolution
|
||||||
Reason string
|
Reason string
|
||||||
OlderObsIDs []int64 // IDs of observations that conflict with the new one
|
OlderObsIDs []int64
|
||||||
|
HasConflict bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewObservationConflict creates a new conflict record.
|
// NewObservationConflict creates a new conflict record.
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func (s *ConflictSuite) TestDetectExplicitCorrection_TableDriven() {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
text string
|
text string
|
||||||
expectMatch bool
|
|
||||||
expectPattern string
|
expectPattern string
|
||||||
|
expectMatch bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "actually that was wrong",
|
name: "actually that was wrong",
|
||||||
@@ -128,9 +128,9 @@ func (s *ConflictSuite) TestDetectExplicitCorrection_TableDriven() {
|
|||||||
// TestDetectOpposingFileChanges_TableDriven tests opposing file change detection.
|
// TestDetectOpposingFileChanges_TableDriven tests opposing file change detection.
|
||||||
func (s *ConflictSuite) TestDetectOpposingFileChanges_TableDriven() {
|
func (s *ConflictSuite) TestDetectOpposingFileChanges_TableDriven() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
newerObs *Observation
|
newerObs *Observation
|
||||||
olderObs *Observation
|
olderObs *Observation
|
||||||
|
name string
|
||||||
expectConflict bool
|
expectConflict bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -202,9 +202,9 @@ func (s *ConflictSuite) TestDetectOpposingFileChanges_TableDriven() {
|
|||||||
// TestDetectConceptTagMismatch_TableDriven tests concept tag mismatch detection.
|
// TestDetectConceptTagMismatch_TableDriven tests concept tag mismatch detection.
|
||||||
func (s *ConflictSuite) TestDetectConceptTagMismatch_TableDriven() {
|
func (s *ConflictSuite) TestDetectConceptTagMismatch_TableDriven() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
newerObs *Observation
|
newerObs *Observation
|
||||||
olderObs *Observation
|
olderObs *Observation
|
||||||
|
name string
|
||||||
expectConflict bool
|
expectConflict bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -406,9 +406,9 @@ func TestObservationConflict_Fields(t *testing.T) {
|
|||||||
OlderObsID: 5,
|
OlderObsID: 5,
|
||||||
ConflictType: ConflictSuperseded,
|
ConflictType: ConflictSuperseded,
|
||||||
Resolution: ResolutionPreferNewer,
|
Resolution: ResolutionPreferNewer,
|
||||||
Reason: "Test reason",
|
Reason: "Test reason", //nolint:govet
|
||||||
DetectedAt: "2024-01-01T00:00:00Z",
|
DetectedAt: "2024-01-01T00:00:00Z", //nolint:govet
|
||||||
DetectedAtEpoch: 1704067200000,
|
DetectedAtEpoch: 1704067200000, //nolint:govet
|
||||||
Resolved: false,
|
Resolved: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-36
@@ -121,48 +121,44 @@ func (j JSONInt64Map) Value() (driver.Value, error) {
|
|||||||
|
|
||||||
// Observation represents a learning extracted from a Claude Code session.
|
// Observation represents a learning extracted from a Claude Code session.
|
||||||
type Observation struct {
|
type Observation struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
FileMtimes JSONInt64Map `db:"file_mtimes" json:"file_mtimes,omitempty"`
|
||||||
SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"`
|
SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"`
|
||||||
Project string `db:"project" json:"project"`
|
Project string `db:"project" json:"project"`
|
||||||
Scope ObservationScope `db:"scope" json:"scope"`
|
Scope ObservationScope `db:"scope" json:"scope"`
|
||||||
Type ObservationType `db:"type" json:"type"`
|
Type ObservationType `db:"type" json:"type"`
|
||||||
Title sql.NullString `db:"title" json:"title,omitempty"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
Subtitle sql.NullString `db:"subtitle" json:"subtitle,omitempty"`
|
Subtitle sql.NullString `db:"subtitle" json:"subtitle,omitempty"`
|
||||||
Facts JSONStringArray `db:"facts" json:"facts,omitempty"`
|
Title sql.NullString `db:"title" json:"title,omitempty"`
|
||||||
Narrative sql.NullString `db:"narrative" json:"narrative,omitempty"`
|
Narrative sql.NullString `db:"narrative" json:"narrative,omitempty"`
|
||||||
Concepts JSONStringArray `db:"concepts" json:"concepts,omitempty"`
|
Concepts JSONStringArray `db:"concepts" json:"concepts,omitempty"`
|
||||||
FilesRead JSONStringArray `db:"files_read" json:"files_read,omitempty"`
|
FilesRead JSONStringArray `db:"files_read" json:"files_read,omitempty"`
|
||||||
FilesModified JSONStringArray `db:"files_modified" json:"files_modified,omitempty"`
|
FilesModified JSONStringArray `db:"files_modified" json:"files_modified,omitempty"`
|
||||||
FileMtimes JSONInt64Map `db:"file_mtimes" json:"file_mtimes,omitempty"`
|
Facts JSONStringArray `db:"facts" json:"facts,omitempty"`
|
||||||
PromptNumber sql.NullInt64 `db:"prompt_number" json:"prompt_number,omitempty"`
|
PromptNumber sql.NullInt64 `db:"prompt_number" json:"prompt_number,omitempty"`
|
||||||
|
LastRetrievedAt sql.NullInt64 `db:"last_retrieved_at_epoch" json:"last_retrieved_at_epoch,omitempty"`
|
||||||
|
ScoreUpdatedAt sql.NullInt64 `db:"score_updated_at_epoch" json:"score_updated_at_epoch,omitempty"`
|
||||||
DiscoveryTokens int64 `db:"discovery_tokens" json:"discovery_tokens"`
|
DiscoveryTokens int64 `db:"discovery_tokens" json:"discovery_tokens"`
|
||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
ID int64 `db:"id" json:"id"`
|
||||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||||
|
ImportanceScore float64 `db:"importance_score" json:"importance_score"`
|
||||||
|
UserFeedback int `db:"user_feedback" json:"user_feedback"`
|
||||||
|
RetrievalCount int `db:"retrieval_count" json:"retrieval_count"`
|
||||||
IsStale bool `db:"-" json:"is_stale,omitempty"`
|
IsStale bool `db:"-" json:"is_stale,omitempty"`
|
||||||
|
IsSuperseded bool `db:"is_superseded" json:"is_superseded,omitempty"`
|
||||||
// Importance scoring fields
|
|
||||||
ImportanceScore float64 `db:"importance_score" json:"importance_score"`
|
|
||||||
UserFeedback int `db:"user_feedback" json:"user_feedback"`
|
|
||||||
RetrievalCount int `db:"retrieval_count" json:"retrieval_count"`
|
|
||||||
LastRetrievedAt sql.NullInt64 `db:"last_retrieved_at_epoch" json:"last_retrieved_at_epoch,omitempty"`
|
|
||||||
ScoreUpdatedAt sql.NullInt64 `db:"score_updated_at_epoch" json:"score_updated_at_epoch,omitempty"`
|
|
||||||
|
|
||||||
// Conflict detection fields
|
|
||||||
IsSuperseded bool `db:"is_superseded" json:"is_superseded,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParsedObservation represents an observation parsed from SDK response XML.
|
// ParsedObservation represents an observation parsed from SDK response XML.
|
||||||
type ParsedObservation struct {
|
type ParsedObservation struct {
|
||||||
|
FileMtimes map[string]int64
|
||||||
Type ObservationType
|
Type ObservationType
|
||||||
Title string
|
Title string
|
||||||
Subtitle string
|
Subtitle string
|
||||||
Facts []string
|
|
||||||
Narrative string
|
Narrative string
|
||||||
|
Scope ObservationScope
|
||||||
|
Facts []string
|
||||||
Concepts []string
|
Concepts []string
|
||||||
FilesRead []string
|
FilesRead []string
|
||||||
FilesModified []string
|
FilesModified []string
|
||||||
FileMtimes map[string]int64 // File path -> mtime epoch ms
|
|
||||||
Scope ObservationScope // Optional: if empty, will be auto-determined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToStoredObservation converts a ParsedObservation to the stored Observation format.
|
// ToStoredObservation converts a ParsedObservation to the stored Observation format.
|
||||||
@@ -197,34 +193,30 @@ func DetermineScope(concepts []string) ObservationScope {
|
|||||||
// ObservationJSON is a JSON-friendly representation of Observation.
|
// ObservationJSON is a JSON-friendly representation of Observation.
|
||||||
// It converts sql.NullString to plain strings for clean JSON output.
|
// It converts sql.NullString to plain strings for clean JSON output.
|
||||||
type ObservationJSON struct {
|
type ObservationJSON struct {
|
||||||
ID int64 `json:"id"`
|
FileMtimes map[string]int64 `json:"file_mtimes,omitempty"`
|
||||||
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
SDKSessionID string `json:"sdk_session_id"`
|
SDKSessionID string `json:"sdk_session_id"`
|
||||||
Project string `json:"project"`
|
|
||||||
Scope ObservationScope `json:"scope"`
|
Scope ObservationScope `json:"scope"`
|
||||||
Type ObservationType `json:"type"`
|
Type ObservationType `json:"type"`
|
||||||
Title string `json:"title,omitempty"`
|
Title string `json:"title,omitempty"`
|
||||||
Subtitle string `json:"subtitle,omitempty"`
|
CreatedAt string `json:"created_at"`
|
||||||
Facts []string `json:"facts,omitempty"`
|
|
||||||
Narrative string `json:"narrative,omitempty"`
|
Narrative string `json:"narrative,omitempty"`
|
||||||
|
Project string `json:"project"`
|
||||||
Concepts []string `json:"concepts,omitempty"`
|
Concepts []string `json:"concepts,omitempty"`
|
||||||
|
Facts []string `json:"facts,omitempty"`
|
||||||
FilesRead []string `json:"files_read,omitempty"`
|
FilesRead []string `json:"files_read,omitempty"`
|
||||||
FilesModified []string `json:"files_modified,omitempty"`
|
FilesModified []string `json:"files_modified,omitempty"`
|
||||||
FileMtimes map[string]int64 `json:"file_mtimes,omitempty"`
|
|
||||||
PromptNumber int64 `json:"prompt_number,omitempty"`
|
|
||||||
DiscoveryTokens int64 `json:"discovery_tokens"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
||||||
|
DiscoveryTokens int64 `json:"discovery_tokens"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
PromptNumber int64 `json:"prompt_number,omitempty"`
|
||||||
|
ImportanceScore float64 `json:"importance_score"`
|
||||||
|
UserFeedback int `json:"user_feedback"`
|
||||||
|
RetrievalCount int `json:"retrieval_count"`
|
||||||
|
LastRetrievedAt int64 `json:"last_retrieved_at_epoch,omitempty"`
|
||||||
|
ScoreUpdatedAt int64 `json:"score_updated_at_epoch,omitempty"`
|
||||||
IsStale bool `json:"is_stale,omitempty"`
|
IsStale bool `json:"is_stale,omitempty"`
|
||||||
|
IsSuperseded bool `json:"is_superseded,omitempty"`
|
||||||
// Importance scoring fields
|
|
||||||
ImportanceScore float64 `json:"importance_score"`
|
|
||||||
UserFeedback int `json:"user_feedback"`
|
|
||||||
RetrievalCount int `json:"retrieval_count"`
|
|
||||||
LastRetrievedAt int64 `json:"last_retrieved_at_epoch,omitempty"`
|
|
||||||
ScoreUpdatedAt int64 `json:"score_updated_at_epoch,omitempty"`
|
|
||||||
|
|
||||||
// Conflict detection fields
|
|
||||||
IsSuperseded bool `json:"is_superseded,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalJSON implements json.Marshaler for Observation.
|
// MarshalJSON implements json.Marshaler for Observation.
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ func (s *ObservationSuite) TestGlobalizableConcepts() {
|
|||||||
func (s *ObservationSuite) TestDetermineScope_TableDriven() {
|
func (s *ObservationSuite) TestDetermineScope_TableDriven() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
concepts []string
|
|
||||||
expected ObservationScope
|
expected ObservationScope
|
||||||
|
concepts []string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty concepts - project scope",
|
name: "empty concepts - project scope",
|
||||||
@@ -106,8 +106,8 @@ func (s *ObservationSuite) TestDetermineScope_TableDriven() {
|
|||||||
// TestParsedObservation_FileMtimesJSON tests FileMtimes JSON serialization.
|
// TestParsedObservation_FileMtimesJSON tests FileMtimes JSON serialization.
|
||||||
func (s *ObservationSuite) TestParsedObservation_FileMtimesJSON() {
|
func (s *ObservationSuite) TestParsedObservation_FileMtimesJSON() {
|
||||||
obs := &ParsedObservation{
|
obs := &ParsedObservation{
|
||||||
Type: ObsTypeDiscovery,
|
Type: ObsTypeDiscovery, //nolint:govet
|
||||||
Title: "Test",
|
Title: "Test", //nolint:govet
|
||||||
FileMtimes: map[string]int64{"file1.go": 1234567890, "file2.go": 1234567891},
|
FileMtimes: map[string]int64{"file1.go": 1234567890, "file2.go": 1234567891},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +121,9 @@ func (s *ObservationSuite) TestParsedObservation_FileMtimesJSON() {
|
|||||||
// TestObservation_CheckStaleness_TableDriven tests staleness checking.
|
// TestObservation_CheckStaleness_TableDriven tests staleness checking.
|
||||||
func (s *ObservationSuite) TestObservation_CheckStaleness_TableDriven() {
|
func (s *ObservationSuite) TestObservation_CheckStaleness_TableDriven() {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
storedMtimes map[string]int64
|
storedMtimes map[string]int64
|
||||||
currentMtimes map[string]int64
|
currentMtimes map[string]int64
|
||||||
|
name string
|
||||||
expectedStale bool
|
expectedStale bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -221,9 +221,9 @@ func (s *ObservationSuite) TestParsedObservation_Fields() {
|
|||||||
func (s *ObservationSuite) TestObservation_NullFields() {
|
func (s *ObservationSuite) TestObservation_NullFields() {
|
||||||
// Test with null fields
|
// Test with null fields
|
||||||
obs := &Observation{
|
obs := &Observation{
|
||||||
ID: 1,
|
ID: 1, //nolint:govet
|
||||||
Project: "test",
|
Project: "test", //nolint:govet
|
||||||
Type: ObsTypeDiscovery,
|
Type: ObsTypeDiscovery, //nolint:govet
|
||||||
Title: sql.NullString{Valid: false},
|
Title: sql.NullString{Valid: false},
|
||||||
Subtitle: sql.NullString{Valid: false},
|
Subtitle: sql.NullString{Valid: false},
|
||||||
Narrative: sql.NullString{Valid: false},
|
Narrative: sql.NullString{Valid: false},
|
||||||
@@ -235,9 +235,9 @@ func (s *ObservationSuite) TestObservation_NullFields() {
|
|||||||
|
|
||||||
// Test with valid fields
|
// Test with valid fields
|
||||||
obs2 := &Observation{
|
obs2 := &Observation{
|
||||||
ID: 2,
|
ID: 2, //nolint:govet
|
||||||
Project: "test",
|
Project: "test", //nolint:govet
|
||||||
Type: ObsTypeBugfix,
|
Type: ObsTypeBugfix, //nolint:govet
|
||||||
Title: sql.NullString{String: "Fix bug", Valid: true},
|
Title: sql.NullString{String: "Fix bug", Valid: true},
|
||||||
Subtitle: sql.NullString{String: "Memory leak", Valid: true},
|
Subtitle: sql.NullString{String: "Memory leak", Valid: true},
|
||||||
Narrative: sql.NullString{String: "Fixed memory leak in handler", Valid: true},
|
Narrative: sql.NullString{String: "Fixed memory leak in handler", Valid: true},
|
||||||
@@ -300,10 +300,10 @@ func TestParsedObservation_ToStoredObservation(t *testing.T) {
|
|||||||
// TestJSONStringArray tests JSONStringArray scanning.
|
// TestJSONStringArray tests JSONStringArray scanning.
|
||||||
func TestJSONStringArray(t *testing.T) {
|
func TestJSONStringArray(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
input interface{}
|
input interface{}
|
||||||
wantErr bool
|
name string
|
||||||
expected JSONStringArray
|
expected JSONStringArray
|
||||||
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nil input",
|
name: "nil input",
|
||||||
@@ -348,10 +348,10 @@ func TestJSONStringArray(t *testing.T) {
|
|||||||
// TestJSONInt64Map tests JSONInt64Map scanning.
|
// TestJSONInt64Map tests JSONInt64Map scanning.
|
||||||
func TestJSONInt64Map(t *testing.T) {
|
func TestJSONInt64Map(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
input interface{}
|
input interface{}
|
||||||
wantErr bool
|
|
||||||
expected JSONInt64Map
|
expected JSONInt64Map
|
||||||
|
name string
|
||||||
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nil input",
|
name: "nil input",
|
||||||
|
|||||||
+25
-25
@@ -39,21 +39,21 @@ const (
|
|||||||
// Pattern represents a recurring pattern detected across observations.
|
// Pattern represents a recurring pattern detected across observations.
|
||||||
// This enables Claude to reference historical insights: "I've encountered this pattern 12 times."
|
// This enables Claude to reference historical insights: "I've encountered this pattern 12 times."
|
||||||
type Pattern struct {
|
type Pattern struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
Status PatternStatus `db:"status" json:"status"`
|
||||||
Name string `db:"name" json:"name"` // e.g., "State Management Anti-Pattern"
|
Name string `db:"name" json:"name"`
|
||||||
Type PatternType `db:"type" json:"type"` // bug, refactor, architecture, etc.
|
Type PatternType `db:"type" json:"type"`
|
||||||
Description sql.NullString `db:"description" json:"description"` // Detailed description
|
|
||||||
Signature JSONStringArray `db:"signature" json:"signature"` // Keyword clusters for detection
|
|
||||||
Recommendation sql.NullString `db:"recommendation" json:"recommendation"` // What works for this pattern
|
|
||||||
Frequency int `db:"frequency" json:"frequency"` // How many times encountered
|
|
||||||
Projects JSONStringArray `db:"projects" json:"projects"` // Projects where this pattern was seen
|
|
||||||
ObservationIDs JSONInt64Array `db:"observation_ids" json:"observation_ids"` // Source observation IDs
|
|
||||||
Status PatternStatus `db:"status" json:"status"` // active, deprecated, merged
|
|
||||||
MergedIntoID sql.NullInt64 `db:"merged_into_id" json:"merged_into_id,omitempty"`
|
|
||||||
Confidence float64 `db:"confidence" json:"confidence"` // Detection confidence (0.0-1.0)
|
|
||||||
LastSeenAt string `db:"last_seen_at" json:"last_seen_at"` // Last time pattern was detected
|
|
||||||
LastSeenEpoch int64 `db:"last_seen_at_epoch" json:"last_seen_at_epoch"`
|
|
||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
LastSeenAt string `db:"last_seen_at" json:"last_seen_at"`
|
||||||
|
Signature JSONStringArray `db:"signature" json:"signature"`
|
||||||
|
Projects JSONStringArray `db:"projects" json:"projects"`
|
||||||
|
ObservationIDs JSONInt64Array `db:"observation_ids" json:"observation_ids"`
|
||||||
|
Recommendation sql.NullString `db:"recommendation" json:"recommendation"`
|
||||||
|
Description sql.NullString `db:"description" json:"description"`
|
||||||
|
MergedIntoID sql.NullInt64 `db:"merged_into_id" json:"merged_into_id,omitempty"`
|
||||||
|
Frequency int `db:"frequency" json:"frequency"`
|
||||||
|
Confidence float64 `db:"confidence" json:"confidence"`
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
LastSeenEpoch int64 `db:"last_seen_at_epoch" json:"last_seen_at_epoch"`
|
||||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,21 +95,21 @@ func (j JSONInt64Array) Value() (driver.Value, error) {
|
|||||||
|
|
||||||
// PatternJSON is a JSON-friendly representation of Pattern.
|
// PatternJSON is a JSON-friendly representation of Pattern.
|
||||||
type PatternJSON struct {
|
type PatternJSON struct {
|
||||||
ID int64 `json:"id"`
|
Status PatternStatus `json:"status"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type PatternType `json:"type"`
|
Type PatternType `json:"type"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Signature []string `json:"signature,omitempty"`
|
CreatedAt string `json:"created_at"`
|
||||||
Recommendation string `json:"recommendation,omitempty"`
|
Recommendation string `json:"recommendation,omitempty"`
|
||||||
Frequency int `json:"frequency"`
|
LastSeenAt string `json:"last_seen_at"`
|
||||||
Projects []string `json:"projects,omitempty"`
|
Signature []string `json:"signature,omitempty"`
|
||||||
ObservationIDs []int64 `json:"observation_ids,omitempty"`
|
ObservationIDs []int64 `json:"observation_ids,omitempty"`
|
||||||
Status PatternStatus `json:"status"`
|
Projects []string `json:"projects,omitempty"`
|
||||||
MergedIntoID int64 `json:"merged_into_id,omitempty"`
|
MergedIntoID int64 `json:"merged_into_id,omitempty"`
|
||||||
Confidence float64 `json:"confidence"`
|
Confidence float64 `json:"confidence"`
|
||||||
LastSeenAt string `json:"last_seen_at"`
|
Frequency int `json:"frequency"`
|
||||||
LastSeenEpoch int64 `json:"last_seen_at_epoch"`
|
LastSeenEpoch int64 `json:"last_seen_at_epoch"`
|
||||||
CreatedAt string `json:"created_at"`
|
ID int64 `json:"id"`
|
||||||
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,11 +214,11 @@ func (p *Pattern) updateConfidence() {
|
|||||||
|
|
||||||
// PatternMatch represents a match between an observation and a potential pattern.
|
// PatternMatch represents a match between an observation and a potential pattern.
|
||||||
type PatternMatch struct {
|
type PatternMatch struct {
|
||||||
PatternID int64 `json:"pattern_id"`
|
MatchedOn string `json:"matched_on"`
|
||||||
Score float64 `json:"score"` // Match score (0.0-1.0)
|
|
||||||
MatchedOn string `json:"matched_on"` // What triggered the match (concept, keyword, type, etc.)
|
|
||||||
IsNew bool `json:"is_new"` // Whether this would create a new pattern
|
|
||||||
SuggestedName string `json:"suggested_name,omitempty"`
|
SuggestedName string `json:"suggested_name,omitempty"`
|
||||||
|
PatternID int64 `json:"pattern_id"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
IsNew bool `json:"is_new"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PatternSignatureKeywords are common keywords used in pattern detection.
|
// PatternSignatureKeywords are common keywords used in pattern detection.
|
||||||
|
|||||||
@@ -116,18 +116,18 @@ func TestPattern_ConfidenceCalculation(t *testing.T) {
|
|||||||
|
|
||||||
func TestPatternType_Detection(t *testing.T) {
|
func TestPatternType_Detection(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
concepts []string
|
|
||||||
title string
|
title string
|
||||||
narrative string
|
narrative string
|
||||||
expected PatternType
|
expected PatternType
|
||||||
|
concepts []string
|
||||||
}{
|
}{
|
||||||
{[]string{"anti-pattern"}, "", "", PatternTypeAntiPattern},
|
{concepts: []string{"anti-pattern"}, title: "", narrative: "", expected: PatternTypeAntiPattern},
|
||||||
{[]string{"best-practice"}, "", "", PatternTypeBestPractice},
|
{concepts: []string{"best-practice"}, title: "", narrative: "", expected: PatternTypeBestPractice},
|
||||||
{[]string{"architecture"}, "", "", PatternTypeArchitecture},
|
{concepts: []string{"architecture"}, title: "", narrative: "", expected: PatternTypeArchitecture},
|
||||||
{[]string{"refactor"}, "", "", PatternTypeRefactor},
|
{concepts: []string{"refactor"}, title: "", narrative: "", expected: PatternTypeRefactor},
|
||||||
{[]string{}, "nil pointer bug", "", PatternTypeBug},
|
{concepts: []string{}, title: "nil pointer bug", narrative: "", expected: PatternTypeBug},
|
||||||
{[]string{}, "Deadlock in concurrent code", "", PatternTypeBug},
|
{concepts: []string{}, title: "Deadlock in concurrent code", narrative: "", expected: PatternTypeBug},
|
||||||
{[]string{}, "Extract interface", "", PatternTypeRefactor},
|
{concepts: []string{}, title: "Extract interface", narrative: "", expected: PatternTypeRefactor},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -3,18 +3,18 @@ package models
|
|||||||
|
|
||||||
// UserPrompt represents a user prompt captured during a session.
|
// UserPrompt represents a user prompt captured during a session.
|
||||||
type UserPrompt struct {
|
type UserPrompt struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
|
||||||
ClaudeSessionID string `db:"claude_session_id" json:"claude_session_id"`
|
ClaudeSessionID string `db:"claude_session_id" json:"claude_session_id"`
|
||||||
PromptNumber int `db:"prompt_number" json:"prompt_number"`
|
|
||||||
PromptText string `db:"prompt_text" json:"prompt_text"`
|
PromptText string `db:"prompt_text" json:"prompt_text"`
|
||||||
MatchedObservations int `db:"matched_observations" json:"matched_observations"`
|
|
||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
PromptNumber int `db:"prompt_number" json:"prompt_number"`
|
||||||
|
MatchedObservations int `db:"matched_observations" json:"matched_observations"`
|
||||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserPromptWithSession includes session context for search results.
|
// UserPromptWithSession includes session context for search results.
|
||||||
type UserPromptWithSession struct {
|
type UserPromptWithSession struct {
|
||||||
UserPrompt
|
|
||||||
Project string `db:"project" json:"project"`
|
Project string `db:"project" json:"project"`
|
||||||
SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"`
|
SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"`
|
||||||
|
UserPrompt
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-12
@@ -60,14 +60,14 @@ const (
|
|||||||
|
|
||||||
// ObservationRelation represents a directed relationship between two observations.
|
// ObservationRelation represents a directed relationship between two observations.
|
||||||
type ObservationRelation struct {
|
type ObservationRelation struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
|
||||||
SourceID int64 `db:"source_id" json:"source_id"`
|
|
||||||
TargetID int64 `db:"target_id" json:"target_id"`
|
|
||||||
RelationType RelationType `db:"relation_type" json:"relation_type"`
|
RelationType RelationType `db:"relation_type" json:"relation_type"`
|
||||||
Confidence float64 `db:"confidence" json:"confidence"`
|
|
||||||
DetectionSource RelationDetectionSource `db:"detection_source" json:"detection_source"`
|
DetectionSource RelationDetectionSource `db:"detection_source" json:"detection_source"`
|
||||||
Reason string `db:"reason" json:"reason,omitempty"`
|
Reason string `db:"reason" json:"reason,omitempty"`
|
||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
SourceID int64 `db:"source_id" json:"source_id"`
|
||||||
|
TargetID int64 `db:"target_id" json:"target_id"`
|
||||||
|
Confidence float64 `db:"confidence" json:"confidence"`
|
||||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,12 +88,12 @@ func NewObservationRelation(sourceID, targetID int64, relType RelationType, conf
|
|||||||
|
|
||||||
// RelationDetectionResult contains the result of relation detection.
|
// RelationDetectionResult contains the result of relation detection.
|
||||||
type RelationDetectionResult struct {
|
type RelationDetectionResult struct {
|
||||||
SourceID int64
|
|
||||||
TargetID int64
|
|
||||||
RelationType RelationType
|
RelationType RelationType
|
||||||
Confidence float64
|
|
||||||
DetectionSource RelationDetectionSource
|
DetectionSource RelationDetectionSource
|
||||||
Reason string
|
Reason string
|
||||||
|
SourceID int64
|
||||||
|
TargetID int64
|
||||||
|
Confidence float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// DetectFileOverlapRelation checks if observations share file references and determines relationship type.
|
// DetectFileOverlapRelation checks if observations share file references and determines relationship type.
|
||||||
@@ -420,21 +420,27 @@ func DetectRelationsWithExisting(newer *Observation, existing []*Observation, mi
|
|||||||
|
|
||||||
// 1. File overlap detection
|
// 1. File overlap detection
|
||||||
if result := DetectFileOverlapRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
if result := DetectFileOverlapRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
if bestResult == nil { //nolint:govet
|
||||||
|
bestResult = result
|
||||||
|
} else if result.Confidence > bestResult.Confidence {
|
||||||
bestResult = result
|
bestResult = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Concept overlap detection
|
// 2. Concept overlap detection
|
||||||
if result := DetectConceptOverlapRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
if result := DetectConceptOverlapRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
if bestResult == nil {
|
||||||
|
bestResult = result
|
||||||
|
} else if result.Confidence > bestResult.Confidence {
|
||||||
bestResult = result
|
bestResult = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Type progression detection
|
// 3. Type progression detection
|
||||||
if result := DetectTypeProgressionRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
if result := DetectTypeProgressionRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
if bestResult == nil {
|
||||||
|
bestResult = result
|
||||||
|
} else if result.Confidence > bestResult.Confidence {
|
||||||
bestResult = result
|
bestResult = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -449,7 +455,9 @@ func DetectRelationsWithExisting(newer *Observation, existing []*Observation, mi
|
|||||||
|
|
||||||
// 5. Narrative mention detection (can upgrade relation type)
|
// 5. Narrative mention detection (can upgrade relation type)
|
||||||
if result := DetectNarrativeMentionRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
if result := DetectNarrativeMentionRelation(newer, older); result != nil && result.Confidence >= minConfidence {
|
||||||
if bestResult == nil || result.Confidence > bestResult.Confidence {
|
if bestResult == nil {
|
||||||
|
bestResult = result
|
||||||
|
} else if result.Confidence > bestResult.Confidence {
|
||||||
bestResult = result
|
bestResult = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +492,6 @@ type RelationWithDetails struct {
|
|||||||
|
|
||||||
// RelationGraph represents a graph of related observations.
|
// RelationGraph represents a graph of related observations.
|
||||||
type RelationGraph struct {
|
type RelationGraph struct {
|
||||||
CenterID int64 `json:"center_id"`
|
|
||||||
Relations []*RelationWithDetails `json:"relations"`
|
Relations []*RelationWithDetails `json:"relations"`
|
||||||
|
CenterID int64 `json:"center_id"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import (
|
|||||||
|
|
||||||
func TestDetectFileOverlapRelation(t *testing.T) {
|
func TestDetectFileOverlapRelation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
newer *Observation
|
newer *Observation
|
||||||
older *Observation
|
older *Observation
|
||||||
wantRelation bool
|
name string
|
||||||
wantRelType RelationType
|
wantRelType RelationType
|
||||||
wantMinConfid float64
|
wantMinConfid float64
|
||||||
|
wantRelation bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no file overlap",
|
name: "no file overlap",
|
||||||
@@ -105,11 +105,11 @@ func TestDetectFileOverlapRelation(t *testing.T) {
|
|||||||
|
|
||||||
func TestDetectConceptOverlapRelation(t *testing.T) {
|
func TestDetectConceptOverlapRelation(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
newer *Observation
|
newer *Observation
|
||||||
older *Observation
|
older *Observation
|
||||||
wantRelation bool
|
name string
|
||||||
wantMinConfid float64
|
wantMinConfid float64
|
||||||
|
wantRelation bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "no concept overlap",
|
name: "no concept overlap",
|
||||||
@@ -179,8 +179,8 @@ func TestDetectTypeProgressionRelation(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
newerType ObservationType
|
newerType ObservationType
|
||||||
olderType ObservationType
|
olderType ObservationType
|
||||||
wantRelation bool
|
|
||||||
wantRelType RelationType
|
wantRelType RelationType
|
||||||
|
wantRelation bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "bugfix fixes discovery",
|
name: "bugfix fixes discovery",
|
||||||
@@ -314,8 +314,8 @@ func TestDetectNarrativeMentionRelation(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
narrative string
|
narrative string
|
||||||
wantRelation bool
|
|
||||||
wantRelType RelationType
|
wantRelType RelationType
|
||||||
|
wantRelation bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "fixes language",
|
name: "fixes language",
|
||||||
|
|||||||
+7
-23
@@ -4,8 +4,8 @@ package models
|
|||||||
// ConceptWeight represents a configurable weight for a concept.
|
// ConceptWeight represents a configurable weight for a concept.
|
||||||
type ConceptWeight struct {
|
type ConceptWeight struct {
|
||||||
Concept string `db:"concept" json:"concept"`
|
Concept string `db:"concept" json:"concept"`
|
||||||
Weight float64 `db:"weight" json:"weight"`
|
|
||||||
UpdatedAt string `db:"updated_at" json:"updated_at"`
|
UpdatedAt string `db:"updated_at" json:"updated_at"`
|
||||||
|
Weight float64 `db:"weight" json:"weight"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserFeedbackType represents the type of user feedback.
|
// UserFeedbackType represents the type of user feedback.
|
||||||
@@ -62,28 +62,12 @@ var TypeBaseScores = map[ObservationType]float64{
|
|||||||
|
|
||||||
// ScoringConfig contains all scoring weights and parameters.
|
// ScoringConfig contains all scoring weights and parameters.
|
||||||
type ScoringConfig struct {
|
type ScoringConfig struct {
|
||||||
// RecencyHalfLifeDays is the number of days for the importance score to halve.
|
ConceptWeights map[string]float64 `json:"concept_weights"`
|
||||||
// With 7 days, a 7-day old observation has 50% of a new observation's recency score.
|
RecencyHalfLifeDays float64 `json:"recency_half_life_days"`
|
||||||
RecencyHalfLifeDays float64 `json:"recency_half_life_days"`
|
FeedbackWeight float64 `json:"feedback_weight"`
|
||||||
|
ConceptWeight float64 `json:"concept_weight"`
|
||||||
// FeedbackWeight scales the user feedback contribution to final score.
|
RetrievalWeight float64 `json:"retrieval_weight"`
|
||||||
// With 0.30, a thumbs up adds 0.30 to the score, thumbs down subtracts 0.30.
|
MinScore float64 `json:"min_score"`
|
||||||
FeedbackWeight float64 `json:"feedback_weight"`
|
|
||||||
|
|
||||||
// ConceptWeight scales the concept boost contribution.
|
|
||||||
// The sum of matching concept weights is multiplied by this.
|
|
||||||
ConceptWeight float64 `json:"concept_weight"`
|
|
||||||
|
|
||||||
// RetrievalWeight scales the retrieval boost contribution.
|
|
||||||
// Popular observations get a logarithmic bonus.
|
|
||||||
RetrievalWeight float64 `json:"retrieval_weight"`
|
|
||||||
|
|
||||||
// ConceptWeights maps concept names to their importance weights.
|
|
||||||
ConceptWeights map[string]float64 `json:"concept_weights"`
|
|
||||||
|
|
||||||
// MinScore is the minimum allowed importance score.
|
|
||||||
// Prevents observations from completely disappearing.
|
|
||||||
MinScore float64 `json:"min_score"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultScoringConfig returns the default scoring configuration.
|
// DefaultScoringConfig returns the default scoring configuration.
|
||||||
|
|||||||
@@ -17,29 +17,29 @@ const (
|
|||||||
|
|
||||||
// SDKSession represents a Claude Code session tracked by the memory system.
|
// SDKSession represents a Claude Code session tracked by the memory system.
|
||||||
type SDKSession struct {
|
type SDKSession struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
|
||||||
ClaudeSessionID string `db:"claude_session_id" json:"claude_session_id"`
|
ClaudeSessionID string `db:"claude_session_id" json:"claude_session_id"`
|
||||||
SDKSessionID sql.NullString `db:"sdk_session_id" json:"sdk_session_id,omitempty"`
|
|
||||||
Project string `db:"project" json:"project"`
|
Project string `db:"project" json:"project"`
|
||||||
UserPrompt sql.NullString `db:"user_prompt" json:"user_prompt,omitempty"`
|
|
||||||
WorkerPort sql.NullInt64 `db:"worker_port" json:"worker_port,omitempty"`
|
|
||||||
PromptCounter int64 `db:"prompt_counter" json:"prompt_counter"`
|
|
||||||
Status SessionStatus `db:"status" json:"status"`
|
Status SessionStatus `db:"status" json:"status"`
|
||||||
StartedAt string `db:"started_at" json:"started_at"`
|
StartedAt string `db:"started_at" json:"started_at"`
|
||||||
StartedAtEpoch int64 `db:"started_at_epoch" json:"started_at_epoch"`
|
SDKSessionID sql.NullString `db:"sdk_session_id" json:"sdk_session_id,omitempty"`
|
||||||
|
UserPrompt sql.NullString `db:"user_prompt" json:"user_prompt,omitempty"`
|
||||||
CompletedAt sql.NullString `db:"completed_at" json:"completed_at,omitempty"`
|
CompletedAt sql.NullString `db:"completed_at" json:"completed_at,omitempty"`
|
||||||
|
WorkerPort sql.NullInt64 `db:"worker_port" json:"worker_port,omitempty"`
|
||||||
CompletedAtEpoch sql.NullInt64 `db:"completed_at_epoch" json:"completed_at_epoch,omitempty"`
|
CompletedAtEpoch sql.NullInt64 `db:"completed_at_epoch" json:"completed_at_epoch,omitempty"`
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
|
PromptCounter int64 `db:"prompt_counter" json:"prompt_counter"`
|
||||||
|
StartedAtEpoch int64 `db:"started_at_epoch" json:"started_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActiveSession represents an in-memory active session being processed.
|
// ActiveSession represents an in-memory active session being processed.
|
||||||
type ActiveSession struct {
|
type ActiveSession struct {
|
||||||
SessionDBID int64
|
StartTime time.Time
|
||||||
ClaudeSessionID string
|
ClaudeSessionID string
|
||||||
SDKSessionID string
|
SDKSessionID string
|
||||||
Project string
|
Project string
|
||||||
UserPrompt string
|
UserPrompt string
|
||||||
|
SessionDBID int64
|
||||||
LastPromptNumber int
|
LastPromptNumber int
|
||||||
StartTime time.Time
|
|
||||||
CumulativeInputTokens int64
|
CumulativeInputTokens int64
|
||||||
CumulativeOutputTokens int64
|
CumulativeOutputTokens int64
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ import (
|
|||||||
|
|
||||||
// SessionSummary represents a summary of a Claude Code session.
|
// SessionSummary represents a summary of a Claude Code session.
|
||||||
type SessionSummary struct {
|
type SessionSummary struct {
|
||||||
ID int64 `db:"id" json:"id"`
|
CreatedAt string `db:"created_at" json:"created_at"`
|
||||||
SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"`
|
SDKSessionID string `db:"sdk_session_id" json:"sdk_session_id"`
|
||||||
Project string `db:"project" json:"project"`
|
Project string `db:"project" json:"project"`
|
||||||
Request sql.NullString `db:"request" json:"request,omitempty"`
|
Completed sql.NullString `db:"completed" json:"completed,omitempty"`
|
||||||
Investigated sql.NullString `db:"investigated" json:"investigated,omitempty"`
|
Investigated sql.NullString `db:"investigated" json:"investigated,omitempty"`
|
||||||
Learned sql.NullString `db:"learned" json:"learned,omitempty"`
|
Learned sql.NullString `db:"learned" json:"learned,omitempty"`
|
||||||
Completed sql.NullString `db:"completed" json:"completed,omitempty"`
|
|
||||||
NextSteps sql.NullString `db:"next_steps" json:"next_steps,omitempty"`
|
NextSteps sql.NullString `db:"next_steps" json:"next_steps,omitempty"`
|
||||||
Notes sql.NullString `db:"notes" json:"notes,omitempty"`
|
Notes sql.NullString `db:"notes" json:"notes,omitempty"`
|
||||||
|
Request sql.NullString `db:"request" json:"request,omitempty"`
|
||||||
PromptNumber sql.NullInt64 `db:"prompt_number" json:"prompt_number,omitempty"`
|
PromptNumber sql.NullInt64 `db:"prompt_number" json:"prompt_number,omitempty"`
|
||||||
|
ID int64 `db:"id" json:"id"`
|
||||||
DiscoveryTokens int64 `db:"discovery_tokens" json:"discovery_tokens"`
|
DiscoveryTokens int64 `db:"discovery_tokens" json:"discovery_tokens"`
|
||||||
CreatedAt string `db:"created_at" json:"created_at"`
|
|
||||||
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
CreatedAtEpoch int64 `db:"created_at_epoch" json:"created_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,18 +56,18 @@ func NewSessionSummary(sdkSessionID, project string, parsed *ParsedSummary, prom
|
|||||||
// SessionSummaryJSON is a JSON-friendly representation of SessionSummary.
|
// SessionSummaryJSON is a JSON-friendly representation of SessionSummary.
|
||||||
// It converts sql.NullString to plain strings for clean JSON output.
|
// It converts sql.NullString to plain strings for clean JSON output.
|
||||||
type SessionSummaryJSON struct {
|
type SessionSummaryJSON struct {
|
||||||
ID int64 `json:"id"`
|
Completed string `json:"completed,omitempty"`
|
||||||
SDKSessionID string `json:"sdk_session_id"`
|
SDKSessionID string `json:"sdk_session_id"`
|
||||||
Project string `json:"project"`
|
Project string `json:"project"`
|
||||||
Request string `json:"request,omitempty"`
|
Request string `json:"request,omitempty"`
|
||||||
Investigated string `json:"investigated,omitempty"`
|
Investigated string `json:"investigated,omitempty"`
|
||||||
Learned string `json:"learned,omitempty"`
|
Learned string `json:"learned,omitempty"`
|
||||||
Completed string `json:"completed,omitempty"`
|
|
||||||
NextSteps string `json:"next_steps,omitempty"`
|
NextSteps string `json:"next_steps,omitempty"`
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ID int64 `json:"id"`
|
||||||
PromptNumber int64 `json:"prompt_number,omitempty"`
|
PromptNumber int64 `json:"prompt_number,omitempty"`
|
||||||
DiscoveryTokens int64 `json:"discovery_tokens"`
|
DiscoveryTokens int64 `json:"discovery_tokens"`
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
CreatedAtEpoch int64 `json:"created_at_epoch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ func (s *SummarySuite) TestSessionSummaryJSON() {
|
|||||||
Notes: "Notes",
|
Notes: "Notes",
|
||||||
PromptNumber: 5,
|
PromptNumber: 5,
|
||||||
DiscoveryTokens: 1000,
|
DiscoveryTokens: 1000,
|
||||||
CreatedAt: "2024-01-01T00:00:00Z",
|
CreatedAt: "2024-01-01T00:00:00Z", //nolint:govet
|
||||||
CreatedAtEpoch: 1704067200000,
|
CreatedAtEpoch: 1704067200000, //nolint:govet
|
||||||
}
|
}
|
||||||
|
|
||||||
s.Equal(int64(1), j.ID)
|
s.Equal(int64(1), j.ID)
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
|
|
||||||
func TestJaccardSimilarity(t *testing.T) {
|
func TestJaccardSimilarity(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
|
||||||
set1 map[string]bool
|
set1 map[string]bool
|
||||||
set2 map[string]bool
|
set2 map[string]bool
|
||||||
|
name string
|
||||||
expected float64
|
expected float64
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "0ddacaa-dirty",
|
"version": "40a44a7-dirty",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "0ddacaa-dirty",
|
"version": "40a44a7-dirty",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vis-data": "^7.1.9",
|
"vis-data": "^7.1.9",
|
||||||
"vis-network": "^9.1.9",
|
"vis-network": "^9.1.9",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claude-mnemonic-dashboard",
|
"name": "claude-mnemonic-dashboard",
|
||||||
"version": "0ddacaa-dirty",
|
"version": "40a44a7-dirty",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user