2c0f8fa257
Refs #1 - F1: canonical fingerprints (git identity by remote URL, log by basename, obsidian by relative vault path) so identical facts get identical ids across machines - F2: node_id (persisted in settings) + Hybrid Logical Clock in all observation "when" columns; removed time.Now() fact-time fallbacks - F3: stable TF-IDF tie-break sort (score desc, term asc) - F4: observations carry (node_id, hcl) locator; dedup ordered by hcl
139 lines
3.2 KiB
Go
139 lines
3.2 KiB
Go
package ingest
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var _ Ingester = (*LogIngester)(nil)
|
|
|
|
type LogIngester struct {
|
|
Version string
|
|
}
|
|
|
|
func NewLogIngester() *LogIngester {
|
|
return &LogIngester{Version: "log-parser/v1"}
|
|
}
|
|
|
|
func (l *LogIngester) SourceID() string { return "opencode-log" }
|
|
|
|
var (
|
|
sessionIDRE = regexp.MustCompile(`session[=_ ]?([a-zA-Z0-9_-]+)`)
|
|
projectIDRE = regexp.MustCompile(`project[=_ ]?([a-zA-Z0-9_.-]+)`)
|
|
modelRE = regexp.MustCompile(`model[=_ ]?([a-zA-Z0-9_.-]+)`)
|
|
agentRE = regexp.MustCompile(`agent[=_ ]?([a-zA-Z0-9_.-]+)`)
|
|
errorRE = regexp.MustCompile(`(?i)(error|fail|exception|panic|timeout)`)
|
|
)
|
|
|
|
func (l *LogIngester) Ingest(path string) (*IngestResult, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open log: %w", err)
|
|
}
|
|
defer f.Close()
|
|
|
|
var (
|
|
sessionIDs []string
|
|
models []string
|
|
agents []string
|
|
errors []string
|
|
totalLines int
|
|
firstError int
|
|
)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
totalLines++
|
|
|
|
if m := sessionIDRE.FindStringSubmatch(line); len(m) > 1 {
|
|
sessionIDs = append(sessionIDs, m[1])
|
|
}
|
|
if m := modelRE.FindStringSubmatch(line); len(m) > 1 {
|
|
models = append(models, m[1])
|
|
}
|
|
if m := agentRE.FindStringSubmatch(line); len(m) > 1 {
|
|
agents = append(agents, m[1])
|
|
}
|
|
if errorRE.MatchString(line) {
|
|
if len(errors) == 0 {
|
|
firstError = totalLines
|
|
}
|
|
errors = append(errors, truncate(line, 120))
|
|
}
|
|
}
|
|
|
|
base := filepath.Base(path)
|
|
fp := Fingerprint([]byte("log:" + base))
|
|
title := fmt.Sprintf("Log %s (%d lines)", base, totalLines)
|
|
createdAt := ""
|
|
if fi, err := os.Stat(path); err == nil {
|
|
createdAt = fi.ModTime().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
var summaryParts []string
|
|
if len(sessionIDs) > 0 {
|
|
summaryParts = append(summaryParts, fmt.Sprintf("sessions: %s", uniqueJoin(sessionIDs, 5)))
|
|
}
|
|
if len(models) > 0 {
|
|
summaryParts = append(summaryParts, fmt.Sprintf("models: %s", uniqueJoin(models, 3)))
|
|
}
|
|
if len(errors) > 0 {
|
|
summaryParts = append(summaryParts, fmt.Sprintf("%d errors (line %d)", len(errors), firstError))
|
|
}
|
|
summary := strings.Join(summaryParts, " | ")
|
|
if summary == "" {
|
|
summary = fmt.Sprintf("%d lines", totalLines)
|
|
}
|
|
|
|
confidence := 0.6
|
|
if len(errors) > 0 {
|
|
confidence = 0.8
|
|
}
|
|
|
|
return &IngestResult{
|
|
Fingerprint: fp,
|
|
SourceID: l.SourceID(),
|
|
SourcePath: base,
|
|
ContentType: ".log",
|
|
Title: title,
|
|
Summary: summary,
|
|
CreatedAt: createdAt,
|
|
Confidence: confidence,
|
|
IngesterVersion: l.Version,
|
|
LineEnd: totalLines,
|
|
Provenance: map[string]any{
|
|
"session_ids": uniqueSlice(sessionIDs),
|
|
"models": uniqueSlice(models),
|
|
"agents": uniqueSlice(agents),
|
|
"error_count": len(errors),
|
|
"file_size": fmt.Sprintf("%d lines", totalLines),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func uniqueJoin(items []string, max int) string {
|
|
uniq := uniqueSlice(items)
|
|
if len(uniq) > max {
|
|
uniq = uniq[:max]
|
|
}
|
|
return strings.Join(uniq, ", ")
|
|
}
|
|
|
|
func uniqueSlice(items []string) []string {
|
|
seen := make(map[string]bool)
|
|
var uniq []string
|
|
for _, s := range items {
|
|
if !seen[s] {
|
|
seen[s] = true
|
|
uniq = append(uniq, s)
|
|
}
|
|
}
|
|
return uniq
|
|
}
|