You've already forked agentic-coding-workflow
217 lines
5.0 KiB
Go
217 lines
5.0 KiB
Go
package codeindex
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
|
|
)
|
|
|
|
// Chunker config defaults. Windows are line-based; the token cap (estimated via
|
|
// llm.EstimateTokens) hard-bounds embedding cost on pathological long lines.
|
|
const (
|
|
DefaultWindowLines = 60
|
|
DefaultOverlapLines = 10
|
|
DefaultMaxFileBytes = 512 * 1024 // skip files larger than this
|
|
DefaultMaxChunkTokens = 1024 // estimated; oversize windows are truncated
|
|
)
|
|
|
|
// Chunker splits file content into overlapping line windows.
|
|
type Chunker struct {
|
|
WindowLines int
|
|
OverlapLines int
|
|
MaxFileBytes int
|
|
MaxChunkTokens int
|
|
}
|
|
|
|
// NewChunker returns a Chunker with platform defaults.
|
|
func NewChunker() *Chunker {
|
|
return &Chunker{
|
|
WindowLines: DefaultWindowLines,
|
|
OverlapLines: DefaultOverlapLines,
|
|
MaxFileBytes: DefaultMaxFileBytes,
|
|
MaxChunkTokens: DefaultMaxChunkTokens,
|
|
}
|
|
}
|
|
|
|
func (c *Chunker) window() int {
|
|
if c.WindowLines > 0 {
|
|
return c.WindowLines
|
|
}
|
|
return DefaultWindowLines
|
|
}
|
|
|
|
func (c *Chunker) overlap() int {
|
|
if c.OverlapLines >= 0 && c.OverlapLines < c.window() {
|
|
return c.OverlapLines
|
|
}
|
|
return DefaultOverlapLines
|
|
}
|
|
|
|
func (c *Chunker) maxFileBytes() int {
|
|
if c.MaxFileBytes > 0 {
|
|
return c.MaxFileBytes
|
|
}
|
|
return DefaultMaxFileBytes
|
|
}
|
|
|
|
func (c *Chunker) maxChunkTokens() int {
|
|
if c.MaxChunkTokens > 0 {
|
|
return c.MaxChunkTokens
|
|
}
|
|
return DefaultMaxChunkTokens
|
|
}
|
|
|
|
// Skippable reports whether a file should not be indexed (binary, oversize, or
|
|
// vendored). Callers should check this before reading large files.
|
|
func (c *Chunker) Skippable(filePath string, size int, content []byte) bool {
|
|
if size > c.maxFileBytes() {
|
|
return true
|
|
}
|
|
if isVendored(filePath) {
|
|
return true
|
|
}
|
|
if isBinary(content) {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Chunk splits content into overlapping line windows. Returns nil for skippable
|
|
// or empty input. ContentSHA is the sha256 of the chunk's exact content so the
|
|
// build runner can reuse already-embedded chunks across commits.
|
|
func (c *Chunker) Chunk(filePath string, content []byte) []Chunk {
|
|
if len(content) == 0 {
|
|
return nil
|
|
}
|
|
if c.Skippable(filePath, len(content), content) {
|
|
return nil
|
|
}
|
|
lines := strings.Split(string(content), "\n")
|
|
// Drop a trailing empty element from a final newline so end_line is accurate.
|
|
if n := len(lines); n > 0 && lines[n-1] == "" {
|
|
lines = lines[:n-1]
|
|
}
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
lang := langForPath(filePath)
|
|
window := c.window()
|
|
step := window - c.overlap()
|
|
if step <= 0 {
|
|
step = window
|
|
}
|
|
maxTok := c.maxChunkTokens()
|
|
|
|
var out []Chunk
|
|
for start := 0; start < len(lines); start += step {
|
|
end := start + window
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
body := strings.Join(lines[start:end], "\n")
|
|
// Hard token cap: truncate runaway windows (minified/one-line files).
|
|
if llm.EstimateTokens(body) > maxTok {
|
|
body = truncateToTokens(body, maxTok)
|
|
}
|
|
sum := sha256.Sum256([]byte(body))
|
|
out = append(out, Chunk{
|
|
StartLine: start + 1, // 1-based, inclusive
|
|
EndLine: end, // 1-based, inclusive
|
|
Lang: lang,
|
|
Content: body,
|
|
ContentSHA: sum[:],
|
|
})
|
|
if end == len(lines) {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// truncateToTokens cuts s down to roughly maxTok estimated tokens (4 chars/token).
|
|
func truncateToTokens(s string, maxTok int) string {
|
|
maxBytes := maxTok * 4
|
|
if len(s) <= maxBytes {
|
|
return s
|
|
}
|
|
// Cut on a UTF-8 boundary.
|
|
for maxBytes > 0 && (s[maxBytes]&0xC0) == 0x80 {
|
|
maxBytes--
|
|
}
|
|
return s[:maxBytes]
|
|
}
|
|
|
|
// isBinary reports whether content looks binary (NUL byte in the first 8KB).
|
|
func isBinary(content []byte) bool {
|
|
n := len(content)
|
|
if n > 8192 {
|
|
n = 8192
|
|
}
|
|
return bytes.IndexByte(content[:n], 0) >= 0
|
|
}
|
|
|
|
// vendoredDirs are path segments whose contents are excluded from the index.
|
|
var vendoredDirs = []string{
|
|
"vendor/", "node_modules/", ".git/", "dist/", "build/", "third_party/",
|
|
"testdata/", ".venv/", "__pycache__/",
|
|
}
|
|
|
|
func isVendored(p string) bool {
|
|
norm := filepath.ToSlash(p)
|
|
for _, d := range vendoredDirs {
|
|
if strings.HasPrefix(norm, d) || strings.Contains(norm, "/"+d) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// langForPath maps a file extension to a coarse language label (best-effort).
|
|
func langForPath(p string) string {
|
|
switch strings.ToLower(filepath.Ext(p)) {
|
|
case ".go":
|
|
return "go"
|
|
case ".ts", ".tsx":
|
|
return "typescript"
|
|
case ".js", ".jsx", ".mjs", ".cjs":
|
|
return "javascript"
|
|
case ".vue":
|
|
return "vue"
|
|
case ".py":
|
|
return "python"
|
|
case ".rs":
|
|
return "rust"
|
|
case ".java":
|
|
return "java"
|
|
case ".c", ".h":
|
|
return "c"
|
|
case ".cpp", ".cc", ".hpp":
|
|
return "cpp"
|
|
case ".rb":
|
|
return "ruby"
|
|
case ".php":
|
|
return "php"
|
|
case ".sql":
|
|
return "sql"
|
|
case ".sh", ".bash":
|
|
return "shell"
|
|
case ".md", ".markdown":
|
|
return "markdown"
|
|
case ".json":
|
|
return "json"
|
|
case ".yaml", ".yml":
|
|
return "yaml"
|
|
case ".toml":
|
|
return "toml"
|
|
case ".html", ".htm":
|
|
return "html"
|
|
case ".css", ".scss":
|
|
return "css"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|