Initial commit: knox knowledge index

This commit is contained in:
2026-08-29 02:52:32 -07:00
commit 8eecde18b4
31 changed files with 6855 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
package ingest
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
const (
chromiumHistoryPath = ".config/chromium/Default/History"
minHistoryPath = ".config/Min/BrowserHistory.db"
)
// noiseURLPatterns are URLs that represent navigation noise rather than knowledge.
var noiseURLPatterns = []string{
"opencode.ai",
"auth.opencode.ai",
"github.com/login/oauth/authorize",
"accounts.google.com",
"google.com/accounts",
"kagi.com",
"chatgpt.com",
// knox indexing its own web UI is self-referential noise
"localhost:8924",
"localhost:18925",
"127.0.0.1:8924",
"127.0.0.1:18925",
}
func noiseURL(url string) bool {
for _, p := range noiseURLPatterns {
if strings.Contains(url, p) {
return true
}
}
return false
}
type BrowserHistoryIngester struct {
Version string
}
func NewBrowserHistoryIngester() *BrowserHistoryIngester {
return &BrowserHistoryIngester{Version: "browser-history/v1"}
}
func (b *BrowserHistoryIngester) SourceID() string { return "browser-history" }
type historyRow struct {
URL string
Title string
VisitTime time.Time
VisitCount int
}
func (b *BrowserHistoryIngester) IngestAll() ([]*IngestResult, error) {
home, _ := os.UserHomeDir()
var results []*IngestResult
// Chromium
chromiumDB := filepath.Join(home, chromiumHistoryPath)
cr, err := b.ingestChromium(chromiumDB)
if err == nil {
results = append(results, cr...)
}
// Min
minDB := filepath.Join(home, minHistoryPath)
minResults, err := b.ingestMin(minDB)
if err == nil {
results = append(results, minResults...)
}
return results, nil
}
func (b *BrowserHistoryIngester) ingestChromium(path string) ([]*IngestResult, error) {
if !fileExists(path) {
return nil, nil
}
// Copy to avoid lock issues (Chromium uses WAL)
tmp := path + ".knox_tmp"
if err := copyFile(path, tmp); err != nil {
return nil, fmt.Errorf("copy chromium db: %w", err)
}
defer os.Remove(tmp)
db, err := sql.Open("sqlite", tmp)
if err != nil {
return nil, fmt.Errorf("open chromium copy: %w", err)
}
defer db.Close()
rows, err := db.Query(
`SELECT url, COALESCE(title,''), last_visit_time, visit_count
FROM urls
WHERE last_visit_time > 0
ORDER BY last_visit_time DESC
LIMIT 1000`,
)
if err != nil {
// Table might not exist or schema mismatch
return nil, fmt.Errorf("query chromium urls: %w", err)
}
defer rows.Close()
var results []*IngestResult
for rows.Next() {
var url, title string
var visitTimeMicro int64
var visitCount int
if err := rows.Scan(&url, &title, &visitTimeMicro, &visitCount); err != nil {
continue
}
// Chromium webkit time: microseconds since 1601-01-01 UTC
visitTime := webkitToTime(visitTimeMicro)
if result := b.makeResult(url, title, visitTime, visitCount); result != nil {
results = append(results, result)
}
}
return results, nil
}
func (b *BrowserHistoryIngester) ingestMin(path string) ([]*IngestResult, error) {
if !fileExists(path) {
return nil, nil
}
tmp := path + ".knox_tmp"
if err := copyFile(path, tmp); err != nil {
return nil, fmt.Errorf("copy min db: %w", err)
}
defer os.Remove(tmp)
db, err := sql.Open("sqlite", tmp)
if err != nil {
return nil, fmt.Errorf("open min copy: %w", err)
}
defer db.Close()
rows, err := db.Query(
`SELECT url, COALESCE(title,''), timestamp
FROM history
ORDER BY timestamp DESC
LIMIT 1000`,
)
if err != nil {
return nil, fmt.Errorf("query min history: %w", err)
}
defer rows.Close()
var results []*IngestResult
for rows.Next() {
var url, title string
var ts int64
if err := rows.Scan(&url, &title, &ts); err != nil {
continue
}
visitTime := time.Unix(ts, 0)
if result := b.makeResult(url, title, visitTime, 1); result != nil {
results = append(results, result)
}
}
return results, nil
}
func (b *BrowserHistoryIngester) makeResult(url, title string, visitTime time.Time, visitCount int) *IngestResult {
if url == "" {
return nil
}
// Skip chrome://, about://, file://, devtools
if strings.HasPrefix(url, "chrome://") || strings.HasPrefix(url, "about:") ||
strings.HasPrefix(url, "file://") || strings.HasPrefix(url, "devtools://") ||
strings.HasPrefix(url, "chrome-extension://") {
return nil
}
if noiseURL(url) {
return nil
}
displayTitle := title
if displayTitle == "" {
displayTitle = extractDomain(url)
}
fp := Fingerprint([]byte(url))
confidence := 0.6
if visitCount > 5 {
confidence = 0.9
} else if visitCount > 1 {
confidence = 0.7
}
return &IngestResult{
Fingerprint: fp,
SourceID: b.SourceID(),
SourcePath: url,
ContentType: "url",
Title: displayTitle,
Summary: url,
CreatedAt: visitTime.Format(time.RFC3339),
Confidence: confidence,
IngesterVersion: b.Version,
Provenance: map[string]any{
"url": url,
"visit_count": visitCount,
"visited_at": visitTime.Format(time.RFC3339),
},
}
}
// webkitToTime converts Chromium webkit timestamp (µs since 1601-01-01) to time.Time
func webkitToTime(micros int64) time.Time {
// WebKit epoch: 1601-01-01 UTC
// Unix epoch: 1970-01-01 UTC
// Difference: 11644473600 seconds
secs := micros / 1_000_000
if secs < 11644473600 {
return time.Time{}
}
return time.Unix(secs-11644473600, 0)
}
func extractDomain(url string) string {
url = strings.TrimPrefix(url, "https://")
url = strings.TrimPrefix(url, "http://")
parts := strings.Split(url, "/")
if len(parts) > 0 {
return parts[0]
}
return url
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
+269
View File
@@ -0,0 +1,269 @@
package ingest
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// GitIngester tracks project status for local git repositories.
//
// For each repo under one or more scan roots (default ~/src) it records the
// current branch, whether the working tree is clean, ahead/behind counts vs
// the upstream, the HEAD hash, and the last commit time + subject. The entry's
// CreatedAt is the last commit time, so staleness/dedup works naturally and
// an entry updates when new commits land.
//
// When Recursive is set, repos are discovered at any depth under a root
// (following symlinked directories), not just direct children — this picks up
// nested repos like ~/assistant/kitmaker or ~/Arduino/*/... Repos already have
// a stable identity via their real (symlink-resolved) path, so overlapping
// roots across symlinks do not duplicate entries.
type GitIngester struct {
Version string
Root string // single scan root; overridden by Roots (empty => $HOME/src)
Roots []string // multi-scan roots; overrides Root when non-empty
Recursive bool // recurse into subdirs (and follow dir symlinks) to find nested .git
}
func NewGitIngester() *GitIngester {
return &GitIngester{Version: "git/v1"}
}
func (g *GitIngester) SourceID() string { return "git" }
// scanRoots returns the effective list of scan roots.
func (g *GitIngester) scanRoots() []string {
if len(g.Roots) > 0 {
return g.Roots
}
if g.Root != "" {
return []string{g.Root}
}
home, err := os.UserHomeDir()
if err != nil {
return nil
}
return []string{filepath.Join(home, "src")}
}
func (g *GitIngester) IngestAll() ([]*IngestResult, error) {
// Dedup by real path so overlapping roots / symlinked roots don't double-add.
seen := make(map[string]struct{})
var results []*IngestResult
for _, root := range g.scanRoots() {
for _, repoPath := range collectGitRepos(root, g.Recursive) {
real, err := filepath.EvalSymlinks(repoPath)
if err != nil {
real = repoPath
}
if _, dup := seen[real]; dup {
continue
}
seen[real] = struct{}{}
if r := g.repoToResult(real); r != nil {
results = append(results, r)
}
}
}
return results, nil
}
func isGitRepo(path string) bool {
info, err := os.Stat(filepath.Join(path, ".git"))
return err == nil && info.IsDir()
}
// collectGitRepos returns paths of git repos under root. In non-recursive mode
// only direct children are considered (root itself is not counted), matching
// the original behavior. In recursive mode the root is walked to any depth,
// descending into symlinked directories, up to and including the root if it is
// itself a git repo. Descending stops once a git repo is found, so nested
// repos (e.g. a vendored copy inside another repo) are treated as leaves.
func collectGitRepos(root string, recursive bool) []string {
visited := make(map[string]struct{})
var out []string
var walk func(dir string)
walk = func(dir string) {
real, err := filepath.EvalSymlinks(dir)
if err != nil {
return
}
if _, ok := visited[real]; ok {
return
}
visited[real] = struct{}{}
if isGitRepo(real) {
out = append(out, real)
return
}
entries, err := os.ReadDir(real)
if err != nil {
return
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), ".") {
continue
}
child := filepath.Join(real, e.Name())
if recursive {
walk(child)
continue
}
if e.IsDir() && isGitRepo(child) {
out = append(out, child)
}
}
}
walk(root)
return out
}
type GitStatus struct {
Branch string
Head string
Dirty int
Ahead int
Behind int
LastCommit string
Subject string
}
func (g *GitIngester) repoToResult(repoPath string) *IngestResult {
status := gitStatus(repoPath)
name := filepath.Base(repoPath)
fp := Fingerprint([]byte("git:repo:" + repoPath))
clean := "clean"
if status.Dirty > 0 {
clean = fmt.Sprintf("%d change(s)", status.Dirty)
}
summary := fmt.Sprintf("%s @ %s — %s", name, status.Branch, clean)
if status.Ahead > 0 || status.Behind > 0 {
summary += fmt.Sprintf(", ahead %d / behind %d", status.Ahead, status.Behind)
}
if status.Subject != "" {
summary += " — “" + truncate(status.Subject, 80) + "”"
}
createdAt := status.LastCommit
if createdAt == "" {
createdAt = gitStatusTime(status)
}
return &IngestResult{
Fingerprint: fp,
SourceID: g.SourceID(),
SourcePath: repoPath,
Project: name,
ContentType: "git-status",
Title: name,
Summary: summary,
CreatedAt: createdAt,
Confidence: 0.9,
IngesterVersion: g.Version,
Provenance: map[string]any{
"type": "git_status",
"path": repoPath,
"branch": status.Branch,
"head": status.Head,
"dirty": status.Dirty,
"ahead": status.Ahead,
"behind": status.Behind,
"last_commit": status.LastCommit,
"subject": status.Subject,
},
}
}
func gitStatusTime(s GitStatus) string {
if s.LastCommit != "" {
return s.LastCommit
}
return time.Now().UTC().Format(time.RFC3339)
}
func gitStatus(repoPath string) GitStatus {
out, err := gitOut(repoPath, "status", "--porcelain")
if err != nil {
return GitStatus{}
}
dirty := 0
if out != "" {
dirty = len(strings.Split(strings.TrimRight(out, "\n"), "\n"))
}
branch, _ := gitOut(repoPath, "rev-parse", "--abbrev-ref", "HEAD")
head, _ := gitOut(repoPath, "rev-parse", "--short", "HEAD")
lastCommit, _ := gitOut(repoPath, "log", "-1", "--format=%cI")
subject, _ := gitOut(repoPath, "log", "-1", "--format=%s")
ahead, behind := 0, 0
// ahead/behind only meaningful if there's an upstream
if upOut, _ := gitOut(repoPath, "rev-parse", "--abbrev-ref", "HEAD@{upstream}"); upOut != "" {
if rev, err := gitOut(repoPath, "rev-list", "--left-right", "--count", "HEAD...@{upstream}"); err == nil {
fields := strings.Fields(rev)
if len(fields) == 2 {
var a, b int
if _, e1 := fmt.Sscanf(fields[0], "%d", &a); e1 == nil {
ahead = a
}
if _, e2 := fmt.Sscanf(fields[1], "%d", &b); e2 == nil {
behind = b
}
}
}
}
return GitStatus{
Branch: strings.TrimSpace(branch),
Head: strings.TrimSpace(head),
Dirty: dirty,
Ahead: ahead,
Behind: behind,
LastCommit: strings.TrimSpace(lastCommit),
Subject: strings.TrimSpace(subject),
}
}
// sanitizeGitField strips ANSI codes and control bytes from git output
// (colour/unicode modes can inject escape sequences).
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
func sanitizeGitField(s string) string {
s = ansiRe.ReplaceAllString(s, "")
s = strings.ReplaceAll(s, "\x1b", "")
return strings.TrimSpace(s)
}
// gitOut runs git in repoPath and returns trimmed stdout (or "" on error).
func gitOut(repoPath string, args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1")
out, err := cmd.Output()
if err != nil {
return "", err
}
return sanitizeGitField(string(out)), nil
}
func (g *GitIngester) Ingest(_ string) (*IngestResult, error) {
return nil, fmt.Errorf("use IngestAll() for git")
}
var _ Ingester = (*GitIngester)(nil)
// SortResults orders results deterministically by project name (helper for CLI output).
func SortResults(res []*IngestResult) {
sort.Slice(res, func(i, j int) bool { return res[i].Project < res[j].Project })
}
+245
View File
@@ -0,0 +1,245 @@
package ingest
import (
"encoding/json"
"fmt"
"os/exec"
"time"
)
type GiteaIngester struct {
Version string
}
func NewGiteaIngester() *GiteaIngester {
return &GiteaIngester{Version: "gitea-tea/v1"}
}
func (g *GiteaIngester) SourceID() string { return "gitea" }
func (g *GiteaIngester) IngestAll() ([]*IngestResult, error) {
var results []*IngestResult
repos, err := g.fetchRepos()
if err == nil {
for _, r := range repos {
if result := g.repoToResult(r); result != nil {
results = append(results, result)
}
}
}
issues, err := g.fetchIssues()
if err == nil {
for _, i := range issues {
if result := g.issueToResult(i); result != nil {
results = append(results, result)
}
}
}
pulls, err := g.fetchPulls()
if err == nil {
for _, p := range pulls {
if result := g.pullToResult(p); result != nil {
results = append(results, result)
}
}
}
return results, nil
}
type teaRepo struct {
Owner string `json:"owner"`
Name string `json:"name"`
Description string `json:"description"`
Updated string `json:"updated"`
}
type teaIssue struct {
Index string `json:"index"`
Title string `json:"title"`
State string `json:"state"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Labels string `json:"labels"`
Author string `json:"author"`
Updated string `json:"updated"`
}
type teaPull struct {
Index string `json:"index"`
Title string `json:"title"`
State string `json:"state"`
Owner string `json:"owner"`
Repo string `json:"repo"`
Updated string `json:"updated"`
}
func (g *GiteaIngester) teaJSON(args ...string) ([][]byte, error) {
cmd := exec.Command("tea", append(args, "--output", "json")...)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("tea %s: %w", args[0], err)
}
var raw json.RawMessage
if err := json.Unmarshal(output, &raw); err != nil {
return nil, err
}
// Handle both array and object responses
var arr []json.RawMessage
if err := json.Unmarshal(raw, &arr); err == nil {
bytes := make([][]byte, len(arr))
for i, a := range arr {
bytes[i] = a
}
return bytes, nil
}
// Single object — wrap in array
return [][]byte{raw}, nil
}
func (g *GiteaIngester) fetchRepos() ([]teaRepo, error) {
items, err := g.teaJSON("repos", "list", "--limit", "100", "--fields", "owner,name,description,updated")
if err != nil {
return nil, err
}
var repos []teaRepo
for _, item := range items {
var r teaRepo
if err := json.Unmarshal(item, &r); err == nil {
repos = append(repos, r)
}
}
return repos, nil
}
func (g *GiteaIngester) fetchIssues() ([]teaIssue, error) {
items, err := g.teaJSON("issues", "list", "--state", "open", "--limit", "100", "--fields", "index,title,state,author,milestone,labels,owner,repo,updated")
if err != nil {
return nil, err
}
var issues []teaIssue
for _, item := range items {
var i teaIssue
if err := json.Unmarshal(item, &i); err == nil {
issues = append(issues, i)
}
}
return issues, nil
}
func (g *GiteaIngester) fetchPulls() ([]teaPull, error) {
items, err := g.teaJSON("pulls", "list", "--state", "open", "--limit", "100", "--fields", "index,title,state,owner,repo,updated")
if err != nil {
return nil, err
}
var pulls []teaPull
for _, item := range items {
var p teaPull
if err := json.Unmarshal(item, &p); err == nil {
pulls = append(pulls, p)
}
}
return pulls, nil
}
// signalTime returns the item's last-activity time, falling back to now.
func signalTime(updated string) string {
if updated != "" {
if _, err := time.Parse(time.RFC3339, updated); err == nil {
return updated
}
}
return time.Now().UTC().Format(time.RFC3339)
}
func (g *GiteaIngester) repoToResult(r teaRepo) *IngestResult {
fullName := r.Owner + "/" + r.Name
fp := Fingerprint([]byte("gitea:repo:" + fullName))
return &IngestResult{
Fingerprint: fp,
SourceID: g.SourceID(),
SourcePath: fullName,
Project: r.Name,
ContentType: "repo",
Title: fullName,
Summary: r.Description,
Confidence: 0.9,
IngesterVersion: g.Version,
CreatedAt: signalTime(r.Updated),
Provenance: map[string]any{
"type": "repository",
"owner": r.Owner,
"name": r.Name,
"description": r.Description,
},
}
}
func (g *GiteaIngester) issueToResult(i teaIssue) *IngestResult {
title := fmt.Sprintf("#%s %s", i.Index, i.Title)
fp := Fingerprint([]byte(fmt.Sprintf("gitea:issue:%s/%s:%s", i.Owner, i.Repo, i.Index)))
summary := fmt.Sprintf("[%s/%s#%s] %s", i.Owner, i.Repo, i.Index, i.Title)
if i.Labels != "" {
summary += " [" + i.Labels + "]"
}
return &IngestResult{
Fingerprint: fp,
SourceID: g.SourceID(),
SourcePath: fmt.Sprintf("%s/%s#%s", i.Owner, i.Repo, i.Index),
Project: i.Repo,
ContentType: "issue",
Title: title,
Summary: summary,
Confidence: 0.85,
IngesterVersion: g.Version,
CreatedAt: signalTime(i.Updated),
Provenance: map[string]any{
"type": "issue",
"owner": i.Owner,
"repo": i.Repo,
"index": i.Index,
"state": i.State,
"labels": i.Labels,
},
}
}
func (g *GiteaIngester) pullToResult(p teaPull) *IngestResult {
title := fmt.Sprintf("!%s %s", p.Index, p.Title)
fp := Fingerprint([]byte(fmt.Sprintf("gitea:pull:%s/%s:%s", p.Owner, p.Repo, p.Index)))
return &IngestResult{
Fingerprint: fp,
SourceID: g.SourceID(),
SourcePath: fmt.Sprintf("%s/%s!%s", p.Owner, p.Repo, p.Index),
Project: p.Repo,
ContentType: "pull",
Title: title,
Summary: fmt.Sprintf("[PR %s/%s] %s", p.Owner, p.Repo, p.Title),
Confidence: 0.85,
IngesterVersion: g.Version,
CreatedAt: signalTime(p.Updated),
Provenance: map[string]any{
"type": "pull_request",
"owner": p.Owner,
"repo": p.Repo,
"index": p.Index,
"state": p.State,
},
}
}
func (g *GiteaIngester) Ingest(_ string) (*IngestResult, error) {
return nil, fmt.Errorf("use IngestAll() for gitea")
}
var _ Ingester = (*GiteaIngester)(nil)
+57
View File
@@ -0,0 +1,57 @@
package ingest
import (
"crypto/sha256"
"encoding/json"
"fmt"
)
type IngestResult struct {
Fingerprint string
SourceID string
SourcePath string
Project string
ContentType string
Title string
Summary string
CreatedAt string
LineStart int
LineEnd int
Confidence float64
IngesterVersion string
Provenance map[string]any
}
type Ingester interface {
SourceID() string
Ingest(path string) (*IngestResult, error)
}
func Fingerprint(data []byte) string {
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h[:16])
}
func FingerprintWithMeta(data []byte, meta map[string]string) string {
h := sha256.New()
h.Write(data)
enc := json.NewEncoder(h)
_ = enc.Encode(meta)
return fmt.Sprintf("%x", h.Sum(nil)[:16])
}
func ProvenanceJSON(m map[string]any) string {
b, err := json.Marshal(m)
if err != nil {
return "{}"
}
return string(b)
}
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "..."
}
+138
View File
@@ -0,0 +1,138 @@
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(path))
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: path,
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
}
+267
View File
@@ -0,0 +1,267 @@
package ingest
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
const obsidianConfigPath = ".config/obsidian/obsidian.json"
type ObsidianIngester struct {
Version string
Vault string // optional override; empty = auto-detect
}
func NewObsidianIngester(vault string) *ObsidianIngester {
return &ObsidianIngester{Version: "obsidian-note/v1", Vault: vault}
}
func (o *ObsidianIngester) SourceID() string { return "obsidian" }
func (o *ObsidianIngester) IngestAll() ([]*IngestResult, error) {
vault := o.Vault
if vault == "" {
var err error
vault, err = detectObsidianVault()
if err != nil {
return nil, fmt.Errorf("no vault path and auto-detect failed: %w", err)
}
}
if !fileExists(vault) {
return nil, fmt.Errorf("vault path %s does not exist", vault)
}
var results []*IngestResult
err := filepath.Walk(vault, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // skip errors per-file
}
if info.IsDir() {
dir := filepath.Base(path)
if strings.HasPrefix(dir, ".") || dir == "node_modules" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".md") {
return nil
}
result, err := o.ingestNote(path, vault)
if err != nil {
return nil
}
if result != nil {
results = append(results, result)
}
return nil
})
return results, err
}
func (o *ObsidianIngester) ingestNote(path, vault string) (*IngestResult, error) {
data, err := os.ReadFile(path)
if err != nil || len(data) < 10 {
return nil, nil
}
content := string(data)
title := extractObsidianTitle(content, path, vault)
tags := extractObsidianTags(content)
created, modified := extractObsidianTimestamps(content, path)
if created == "" {
if fi, err := os.Stat(path); err == nil {
created = fi.ModTime().UTC().Format(time.RFC3339)
}
}
if modified == "" && created != "" {
modified = created
}
// Fingerprint by content (so edits create updates)
fp := FingerprintWithMeta(data, map[string]string{
"source": "obsidian",
"path": path,
})
relPath, _ := filepath.Rel(vault, path)
summary := extractBodyPreview(content, 200)
return &IngestResult{
Fingerprint: fp,
SourceID: o.SourceID(),
SourcePath: relPath,
Project: filepath.Base(vault),
ContentType: ".md",
Title: title,
Summary: summary,
CreatedAt: created,
LineStart: 0,
LineEnd: len(strings.Split(content, "\n")),
Confidence: 0.85,
IngesterVersion: o.Version,
Provenance: map[string]any{
"path": relPath,
"tags": tags,
"modified_at": modified,
"note_title": title,
},
}, nil
}
func DetectObsidianVault() (string, error) { return detectObsidianVault() }
func detectObsidianVault() (string, error) {
home, _ := os.UserHomeDir()
cfgPath := filepath.Join(home, obsidianConfigPath)
if !fileExists(cfgPath) {
// Fallback: check common vault locations
common := []string{
filepath.Join(home, "notes"),
filepath.Join(home, "Documents", "notes"),
filepath.Join(home, "Obsidian"),
filepath.Join(home, "vault"),
}
for _, p := range common {
if fileExists(filepath.Join(p, ".obsidian")) {
return p, nil
}
}
return "", fmt.Errorf("no obsidian config found at %s and no common vault detected", cfgPath)
}
data, err := os.ReadFile(cfgPath)
if err != nil {
return "", err
}
var cfg struct {
Vaults map[string]struct {
Path string `json:"path"`
} `json:"vaults"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
return "", fmt.Errorf("parse obsidian config: %w", err)
}
for _, v := range cfg.Vaults {
if v.Path != "" {
return v.Path, nil
}
}
return "", fmt.Errorf("no vault path in obsidian config")
}
func extractObsidianTitle(content, path, vault string) string {
// Try frontmatter title first
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "title:") {
t := strings.TrimSpace(trimmed[6:])
t = strings.Trim(t, "\"'")
if t != "" {
return t
}
}
if strings.HasPrefix(trimmed, "aliases:") {
break
}
if trimmed == "---" && strings.Count(content[:len(content)/2], "---") >= 2 {
break
}
}
// Fallback: first H1
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# ") {
return strings.TrimSpace(trimmed[2:])
}
}
// Fallback: filename
rel, _ := filepath.Rel(vault, path)
return strings.TrimSuffix(rel, ".md")
}
func extractObsidianTags(content string) []string {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "tags:") {
raw := strings.TrimSpace(trimmed[5:])
raw = strings.Trim(raw, "\"[] ")
var tags []string
for _, t := range strings.Split(raw, ",") {
t = strings.TrimSpace(t)
t = strings.Trim(t, "\" ")
if t != "" {
tags = append(tags, t)
}
}
return tags
}
if trimmed == "---" && strings.Count(content[:len(content)/2], "---") >= 2 {
break
}
}
return nil
}
func extractObsidianTimestamps(content, path string) (created, modified string) {
// Try frontmatter dates
inFrontmatter := false
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
inFrontmatter = !inFrontmatter
continue
}
if !inFrontmatter {
break
}
if strings.HasPrefix(trimmed, "created:") || strings.HasPrefix(trimmed, "date:") {
created = strings.TrimSpace(trimmed[strings.Index(trimmed, ":")+1:])
created = strings.Trim(created, "\"' ")
}
if strings.HasPrefix(trimmed, "modified:") || strings.HasPrefix(trimmed, "updated:") {
modified = strings.TrimSpace(trimmed[strings.Index(trimmed, ":")+1:])
modified = strings.Trim(modified, "\"' ")
}
}
return created, modified
}
func extractBodyPreview(content string, maxLen int) string {
inFrontmatter := false
var body strings.Builder
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "---" {
if inFrontmatter {
inFrontmatter = false
continue
}
inFrontmatter = true
continue
}
if inFrontmatter {
continue
}
if trimmed != "" && body.Len() < maxLen {
if body.Len() > 0 {
body.WriteString(" ")
}
body.WriteString(truncate(trimmed, maxLen-body.Len()))
}
}
result := body.String()
if len(result) > maxLen {
return result[:maxLen] + "..."
}
return result
}
+69
View File
@@ -0,0 +1,69 @@
package ingest
import (
"os"
"path/filepath"
"strings"
"time"
)
var _ Ingester = (*ObsidianFileIngester)(nil)
type ObsidianFileIngester struct {
Version string
Vault string
}
func NewObsidianFileIngester(vault string) *ObsidianFileIngester {
return &ObsidianFileIngester{Version: "obsidian-file/v1", Vault: vault}
}
func (o *ObsidianFileIngester) SourceID() string { return "obsidian" }
func (o *ObsidianFileIngester) Ingest(path string) (*IngestResult, error) {
if !strings.HasSuffix(path, ".md") {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil || len(data) < 10 {
return nil, nil
}
content := string(data)
title := extractObsidianTitle(content, path, o.Vault)
tags := extractObsidianTags(content)
created, _ := extractObsidianTimestamps(content, path)
if created == "" {
if fi, err := os.Stat(path); err == nil {
created = fi.ModTime().UTC().Format(time.RFC3339)
}
}
fp := FingerprintWithMeta(data, map[string]string{
"source": "obsidian",
"path": path,
})
relPath, _ := filepath.Rel(o.Vault, path)
summary := extractBodyPreview(content, 200)
return &IngestResult{
Fingerprint: fp,
SourceID: o.SourceID(),
SourcePath: relPath,
Project: filepath.Base(o.Vault),
ContentType: ".md",
Title: title,
Summary: summary,
CreatedAt: created,
LineEnd: len(strings.Split(content, "\n")),
Confidence: 0.85,
IngesterVersion: o.Version,
Provenance: map[string]any{
"path": relPath,
"tags": tags,
"note_title": title,
},
}, nil
}
+157
View File
@@ -0,0 +1,157 @@
package ingest
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
type jsonKind int
const (
jsonObject jsonKind = iota
jsonArray
)
var _ Ingester = (*SessionDiffIngester)(nil)
type SessionDiffIngester struct {
Version string
}
func NewSessionDiffIngester() *SessionDiffIngester {
return &SessionDiffIngester{Version: "session-diff/v1"}
}
func (s *SessionDiffIngester) SourceID() string { return "opencode-session" }
func (s *SessionDiffIngester) Ingest(path string) (*IngestResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read session diff: %w", err)
}
if len(data) < 10 {
return nil, nil
}
raw, kind := parseJSON(data)
if raw == nil {
return nil, nil
}
sessionID := guessString(raw, "session_id", "id", "sessionId")
project := guessString(raw, "project", "project_id", "projectPath", "projectId")
title := guessString(raw, "title", "name", "label")
createdAt := guessString(raw, "created_at", "timestamp", "date", "started_at")
if createdAt == "" {
if fi, err := os.Stat(path); err == nil {
createdAt = fi.ModTime().UTC().Format(time.RFC3339)
}
}
if sessionID == "" {
base := filepath.Base(path)
sessionID = strings.TrimSuffix(base, filepath.Ext(base))
sessionID = strings.TrimPrefix(sessionID, "ses_")
}
if title == "" {
title = fmt.Sprintf("Session %s", sessionID[:min(8, len(sessionID))])
}
summary := extractSummary(raw, kind)
fp := FingerprintWithMeta(data, map[string]string{
"session_id": sessionID,
"source": "session_diff",
})
confidence := 0.7
if kind == jsonArray {
confidence = 0.4
}
return &IngestResult{
Fingerprint: fp,
SourceID: s.SourceID(),
SourcePath: path,
Project: project,
ContentType: ".json",
Title: title,
Summary: summary,
CreatedAt: createdAt,
LineStart: 0,
LineEnd: 0,
Confidence: confidence,
IngesterVersion: s.Version,
Provenance: map[string]any{
"session_id": sessionID,
"kind": kindName(kind),
"field_count": len(raw),
},
}, nil
}
func parseJSON(data []byte) (map[string]any, jsonKind) {
var obj map[string]any
if err := json.Unmarshal(data, &obj); err == nil {
return obj, jsonObject
}
var arr []any
if err := json.Unmarshal(data, &arr); err == nil {
m := make(map[string]any)
m["_count"] = float64(len(arr))
m["_kind"] = "array"
if len(arr) > 0 {
if first, ok := arr[0].(map[string]any); ok {
for k, v := range first {
if str, ok := v.(string); ok && len(str) < 200 {
m["_sample_"+k] = str
break
}
}
}
}
return m, jsonArray
}
return nil, jsonObject
}
func extractSummary(raw map[string]any, kind jsonKind) string {
if msg, ok := raw["message"].(string); ok && len(msg) > 0 {
return truncate(msg, 200)
}
if msgs, ok := raw["messages"].([]any); ok && len(msgs) > 0 {
parts := make([]string, 0, len(msgs))
for _, m := range msgs {
if mm, ok := m.(map[string]any); ok {
if c, ok := mm["content"].(string); ok {
parts = append(parts, truncate(c, 100))
}
}
}
return strings.Join(parts, " | ")
}
if count, ok := raw["_count"].(float64); ok && kind == jsonArray {
return fmt.Sprintf("Array with %.0f entries", count)
}
b, _ := json.Marshal(raw)
return truncate(string(b), 200)
}
func guessString(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && v != "" {
return v
}
}
return ""
}
func kindName(k jsonKind) string {
if k == jsonArray {
return "array"
}
return "object"
}
+135
View File
@@ -0,0 +1,135 @@
package ingest
import (
"fmt"
"os"
"path/filepath"
"strings"
)
var _ Ingester = (*SkillsIngester)(nil)
type SkillsIngester struct {
Version string
}
func NewSkillsIngester() *SkillsIngester {
return &SkillsIngester{Version: "skills-index/v1"}
}
func (s *SkillsIngester) SourceID() string { return "skills-catalog" }
func (s *SkillsIngester) Ingest(path string) (*IngestResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read skill: %w", err)
}
if len(data) < 20 {
return nil, nil
}
content := string(data)
title := extractSkillName(content, path)
summary := extractSkillSummary(content)
tags := extractSkillTags(content)
projects := extractProjects(tags)
createdAt := extractCreatedAt(content)
fp := FingerprintWithMeta(data, map[string]string{
"source": "skills-catalog",
"title": title,
})
return &IngestResult{
Fingerprint: fp,
SourceID: s.SourceID(),
SourcePath: path,
Project: projects,
ContentType: ".md",
Title: title,
Summary: summary,
CreatedAt: createdAt,
LineStart: 0,
LineEnd: len(strings.Split(content, "\n")),
Confidence: 0.9,
IngesterVersion: s.Version,
Provenance: map[string]any{
"skill_name": title,
"tags": tags,
"line_count": len(strings.Split(content, "\n")),
},
}, nil
}
func extractSkillName(content, path string) string {
base := filepath.Base(filepath.Dir(path))
if base != "" && base != "." && base != "/" {
return base
}
return strings.TrimSuffix(filepath.Base(path), ".md")
}
func extractSkillSummary(content string) string {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "description:") {
desc := strings.TrimSpace(trimmed[len("description:"):])
if len(desc) > 200 {
desc = desc[:200] + "..."
}
return desc
}
}
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "# ") {
continue
}
if trimmed != "" && !strings.HasPrefix(trimmed, "---") {
return truncate(trimmed, 200)
}
}
return ""
}
func extractSkillTags(content string) []string {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "tags:") {
raw := strings.TrimSpace(trimmed[len("tags:"):])
raw = strings.Trim(raw, "\"[]")
var tags []string
for _, t := range strings.Split(raw, ",") {
t = strings.TrimSpace(t)
t = strings.Trim(t, "\" ")
if t != "" {
tags = append(tags, t)
}
}
return tags
}
}
return nil
}
func extractProjects(tags []string) string {
for _, t := range tags {
if strings.HasPrefix(t, "scope:project:") {
return strings.TrimPrefix(t, "scope:project:")
}
if strings.HasPrefix(t, "scope:machine:") {
return "[machine]"
}
}
return ""
}
func extractCreatedAt(content string) string {
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "created_at:") || strings.HasPrefix(trimmed, "updated_at:") {
return strings.TrimSpace(trimmed[strings.Index(trimmed, ":")+1:])
}
}
return ""
}