Files
llmgw/internal/kb/kbsync/sync.go
T
Ilker ManapandClaude Opus 5.5 ba5df90602 llmgw-kb: sync a person's documents into a knowledge base from their own machine
Its own binary for Windows, macOS and Linux, carrying no server code. It reads
the files there, cuts them, has the gateway embed them with the person's key
and sends text and vectors for what changed only: a SQLite ledger on the
machine remembers each document's file, size, time, digest and what happened,
so a rerun reads nothing unchanged, a file skipped stays skipped until
--retry, and --delete removes only what this machine sent under the paths
given. --dry-run says what would leave the machine. Commands: login,
collections, sync, status, history, formats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:43:47 +02:00

575 lines
15 KiB
Go

package kbsync
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"golang.org/x/text/unicode/norm"
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/chunk"
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/extract"
)
// Options say what to sync and how.
type Options struct {
// Collection is the collection's name or id.
Collection string
// Roots are the directories and files to sync.
Roots []string
// Include, when set, are the only files read (globs; one without a "/"
// matches a file's name, one with it the path under its root, "**"
// crossing directories). Exclude are never read.
Include, Exclude []string
// Hidden includes files and directories whose names start with a dot.
Hidden bool
// DryRun reads and cuts everything that would be sent, and sends
// nothing.
DryRun bool
// Delete removes from the collection documents this machine sent whose
// files under these roots are gone.
Delete bool
// Retry reads again files skipped before, as when a helper has been
// installed since.
Retry bool
// Workers is how many files are read and sent at once; 0 is 4.
Workers int
Extract extract.Options
Chunk chunk.Options
}
// Event is something a sync did to one document, for showing progress.
type Event struct {
// Kind is sent, unchanged, skipped, failed, deleted, would-send,
// would-delete, or warning.
Kind string
Name string
Detail string
Chunks int
Bytes int64
}
// Report is what a sync did in all.
type Report struct {
Collection Collection
Files int
Sent int
Unchanged int
Skipped int
Failed int
Deleted int
// Chunks and Bytes are what was sent, or with DryRun what would be.
Chunks int
Bytes int64
}
// maxDocJSON is the most one document's request is, under the gateway's
// limit with room to spare.
const maxDocJSON = 60 << 20
// defaultExcludes are directories nobody means to put in a knowledge base.
var defaultExcludes = map[string]bool{
".git": true, ".svn": true, ".hg": true, "node_modules": true, ".venv": true, "venv": true,
"__pycache__": true, ".idea": true, ".vscode": true, "$RECYCLE.BIN": true,
"System Volume Information": true, ".Trash": true, ".Trashes": true, ".cache": true,
}
type file struct {
abs string
name string
size int64
mtime time.Time
}
// Sync brings a collection up to date with the files under the roots.
func Sync(ctx context.Context, c *Client, l *Ledger, o Options, progress func(Event)) (Report, error) {
var mu sync.Mutex
var rep Report
emit := func(e Event) {
mu.Lock()
defer mu.Unlock()
switch e.Kind {
case "sent", "would-send":
rep.Sent++
rep.Chunks += e.Chunks
rep.Bytes += e.Bytes
case "unchanged":
rep.Unchanged++
case "skipped":
rep.Skipped++
case "failed":
rep.Failed++
case "deleted", "would-delete":
rep.Deleted++
}
if progress != nil {
progress(e)
}
}
m, err := c.Manifest(ctx, o.Collection)
if err != nil {
return rep, fmt.Errorf("reading collection %s: %w", o.Collection, err)
}
rep.Collection = m.Collection
if !m.Collection.Writable && !o.DryRun {
return rep, fmt.Errorf("this key may read collection %s but not add to it", m.Collection.Name)
}
remote, chunks := map[string]string{}, map[string]int{}
for _, d := range m.Documents {
remote[d.Name], chunks[d.Name] = d.Digest, d.Chunks
}
gw := strings.TrimRight(c.Base, "/")
coll := m.Collection.ID
files, roots, err := walk(o, func(name, why string) { emit(Event{Kind: "warning", Name: name, Detail: why}) })
if err != nil {
return rep, err
}
rep.Files = len(files)
if len(files) == 0 {
return rep, ErrNothing
}
runID, err := l.StartRun(ctx, gw, coll)
if err != nil {
return rep, err
}
previous, err := l.Entries(ctx, gw, coll)
if err != nil {
return rep, err
}
byFile := map[string][]Entry{}
for _, e := range previous {
byFile[e.File] = append(byFile[e.File], e)
}
s := &syncer{c: c, l: l, o: o, gw: gw, coll: m.Collection, remote: remote, chunks: chunks, byFile: byFile, emit: emit}
workers := o.Workers
if workers <= 0 {
workers = 4
}
jobs := make(chan file)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for f := range jobs {
s.one(ctx, f)
}
}()
}
for _, f := range files {
select {
case jobs <- f:
case <-ctx.Done():
}
if ctx.Err() != nil {
break
}
}
close(jobs)
wg.Wait()
if o.Delete && ctx.Err() == nil {
seen := map[string]bool{}
for _, f := range files {
seen[f.abs] = true
}
for _, e := range previous {
if e.State != Sent || seen[e.File] || !under(e.File, roots) {
continue
}
if o.DryRun {
emit(Event{Kind: "would-delete", Name: e.Name})
continue
}
if err := c.Delete(ctx, coll, e.Name); err != nil {
emit(Event{Kind: "failed", Name: e.Name, Detail: "deleting: " + err.Error()})
continue
}
_ = l.Remove(ctx, gw, coll, e.Name)
emit(Event{Kind: "deleted", Name: e.Name})
}
}
_ = l.EndRun(context.WithoutCancel(ctx), runID, Run{Sent: rep.Sent, Unchanged: rep.Unchanged, Skipped: rep.Skipped,
Failed: rep.Failed, Delete: rep.Deleted})
return rep, ctx.Err()
}
type syncer struct {
c *Client
l *Ledger
o Options
gw string
coll Collection
remote map[string]string
chunks map[string]int
byFile map[string][]Entry
emit func(Event)
}
func (s *syncer) entry(f file, name, digest string, st State, reason string, chunks int) Entry {
return Entry{Gateway: s.gw, Collection: s.coll.ID, Name: name, File: f.abs, Size: f.size, MTime: f.mtime,
Digest: digest, State: st, Reason: reason, Chunks: chunks}
}
// one syncs one file: every document in it.
func (s *syncer) one(ctx context.Context, f file) {
if ctx.Err() != nil {
return
}
digest, ok := s.l.Known(ctx, f.abs, f.size, f.mtime)
if !ok {
d, err := hashFile(f.abs)
if err != nil {
s.emit(Event{Kind: "skipped", Name: f.name, Detail: err.Error()})
return
}
digest = d
}
prev := s.byFile[f.abs]
// The collection has this file as it is: nothing to read.
if s.remote[f.name] == digest {
s.record(ctx, s.entry(f, f.name, digest, Sent, "", s.chunks[f.name]))
s.emit(Event{Kind: "unchanged", Name: f.name})
return
}
// An archive or a mail is several documents; all of them as they are
// is nothing to read either.
if len(prev) > 0 && allSame(prev, digest, s.remote) && !(s.o.Retry && anySkipped(prev)) {
for _, e := range prev {
if e.State == Skipped {
s.emit(Event{Kind: "skipped", Name: e.Name, Detail: e.Reason + " (as before; --retry reads it again)"})
} else {
s.emit(Event{Kind: "unchanged", Name: e.Name})
}
}
return
}
parts, err := extract.File(ctx, f.abs, s.o.Extract)
if err != nil {
s.skip(ctx, f, f.name, digest, err)
return
}
current := map[string]bool{}
for _, p := range parts {
name := f.name
if p.Name != "" {
name = f.name + "!/" + p.Name
}
current[name] = true
if p.Err != nil {
s.skip(ctx, f, name, digest, p.Err)
continue
}
s.send(ctx, f, name, digest, p.Text)
}
// Members an archive no longer has go with it.
for _, e := range prev {
if current[e.Name] || e.Name == f.name {
continue
}
if s.o.DryRun {
s.emit(Event{Kind: "would-delete", Name: e.Name, Detail: "no longer in " + f.name})
continue
}
if e.State == Sent {
if err := s.c.Delete(ctx, s.coll.ID, e.Name); err != nil {
s.emit(Event{Kind: "failed", Name: e.Name, Detail: "deleting: " + err.Error()})
continue
}
s.emit(Event{Kind: "deleted", Name: e.Name, Detail: "no longer in " + f.name})
}
_ = s.l.Remove(ctx, s.gw, s.coll.ID, e.Name)
}
}
// allSame reports whether every document from a file was left with this
// digest, and the collection still holds those that were sent.
func allSame(prev []Entry, digest string, remote map[string]string) bool {
for _, e := range prev {
if e.Digest != digest {
return false
}
switch e.State {
case Sent:
if remote[e.Name] != digest {
return false
}
case Failed:
return false
}
}
return true
}
func anySkipped(es []Entry) bool {
for _, e := range es {
if e.State == Skipped {
return true
}
}
return false
}
func (s *syncer) skip(ctx context.Context, f file, name, digest string, err error) {
s.record(ctx, s.entry(f, name, digest, Skipped, err.Error(), 0))
s.emit(Event{Kind: "skipped", Name: name, Detail: err.Error()})
}
// record writes the ledger; a dry run leaves it as it was.
func (s *syncer) record(ctx context.Context, e Entry) {
if s.o.DryRun {
return
}
_ = s.l.Put(ctx, e)
}
// send cuts a document's text, embeds it and sends it.
func (s *syncer) send(ctx context.Context, f file, name, digest, text string) {
if s.remote[name] == digest {
s.record(ctx, s.entry(f, name, digest, Sent, "", s.chunks[name]))
s.emit(Event{Kind: "unchanged", Name: name})
return
}
cs := chunk.Split(text, s.o.Chunk)
if len(cs) == 0 {
s.skip(ctx, f, name, digest, extract.ErrNoText)
return
}
texts := make([]string, len(cs))
var size int64
for i, c := range cs {
texts[i] = c.Text()
size += int64(len(texts[i]))
}
// About ten bytes of JSON a dimension.
size += int64(len(cs) * s.coll.Dims * 10)
if size > maxDocJSON {
s.fail(ctx, f, name, digest, fmt.Errorf("%d MiB to send is over the gateway's limit for one document; split the file", size>>20))
return
}
if s.o.DryRun {
s.emit(Event{Kind: "would-send", Name: name, Chunks: len(cs), Bytes: size})
return
}
vecs, err := s.c.Embed(ctx, s.coll.EmbedModel, texts)
if err != nil {
s.fail(ctx, f, name, digest, fmt.Errorf("embedding with %s: %w", s.coll.EmbedModel, err))
return
}
out := DocOut{Name: name, Digest: digest, Bytes: f.size, Chunks: make([]ChunkOut, len(cs))}
for i := range cs {
if len(vecs[i]) != s.coll.Dims {
s.fail(ctx, f, name, digest, fmt.Errorf("%s gave %d dimensions and the collection takes %d",
s.coll.EmbedModel, len(vecs[i]), s.coll.Dims))
return
}
out.Chunks[i] = ChunkOut{Text: texts[i], Embedding: vecs[i]}
}
if _, err := s.c.Put(ctx, s.coll.ID, out); err != nil {
s.fail(ctx, f, name, digest, err)
return
}
s.record(ctx, s.entry(f, name, digest, Sent, "", len(cs)))
s.emit(Event{Kind: "sent", Name: name, Chunks: len(cs), Bytes: size})
}
func (s *syncer) fail(ctx context.Context, f file, name, digest string, err error) {
s.record(ctx, s.entry(f, name, digest, Failed, err.Error(), 0))
s.emit(Event{Kind: "failed", Name: name, Detail: err.Error()})
}
func hashFile(p string) (string, error) {
fh, err := os.Open(p)
if err != nil {
return "", err
}
defer fh.Close()
h := sha256.New()
if _, err := io.Copy(h, fh); err != nil {
return "", err
}
return "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
}
// under reports whether a path is one of the roots or inside one.
func under(p string, roots []string) bool {
for _, r := range roots {
if p == r || strings.HasPrefix(p, strings.TrimRight(r, string(filepath.Separator))+string(filepath.Separator)) {
return true
}
}
return false
}
// walk lists the files to sync, named as documents: the path under its root
// with forward slashes, in Unicode NFC, prefixed by the root's own name when
// there are several roots so that two roots' "README.md" stay apart.
func walk(o Options, warn func(name, why string)) ([]file, []string, error) {
inc, err := globs(o.Include)
if err != nil {
return nil, nil, err
}
exc, err := globs(o.Exclude)
if err != nil {
return nil, nil, err
}
var out []file
var roots []string
seen := map[string]string{}
for _, root := range o.Roots {
abs, err := filepath.Abs(root)
if err != nil {
return nil, nil, err
}
st, err := os.Stat(abs)
if err != nil {
return nil, nil, err
}
roots = append(roots, abs)
prefix := ""
if len(o.Roots) > 1 && st.IsDir() {
prefix = norm.NFC.String(filepath.Base(abs)) + "/"
}
add := func(p string, info fs.FileInfo) {
rel := filepath.Base(p)
if st.IsDir() {
rel, _ = filepath.Rel(abs, p)
}
name := prefix + norm.NFC.String(filepath.ToSlash(rel))
if !wanted(name, inc, exc, o.Hidden) {
return
}
if prev, ok := seen[strings.ToLower(name)]; ok && prev != name {
warn(name, "differs from "+prev+" only in case; on Windows and macOS they are one file")
}
seen[strings.ToLower(name)] = name
out = append(out, file{abs: p, name: name, size: info.Size(), mtime: info.ModTime()})
}
if !st.IsDir() {
add(abs, st)
continue
}
err = filepath.WalkDir(abs, func(p string, d fs.DirEntry, err error) error {
if err != nil {
// A directory this user may not read is skipped, not fatal.
warn(p, err.Error())
if d != nil && d.IsDir() {
return filepath.SkipDir
}
return nil
}
base := d.Name()
if d.IsDir() {
if p != abs && (defaultExcludes[base] || (!o.Hidden && strings.HasPrefix(base, "."))) {
return filepath.SkipDir
}
return nil
}
if !d.Type().IsRegular() {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
add(p, info)
return nil
})
if err != nil {
return nil, nil, err
}
}
sort.Slice(out, func(i, j int) bool { return out[i].name < out[j].name })
return out, roots, nil
}
// wanted applies the filters to a document name.
func wanted(name string, inc, exc []*regexp.Regexp, hidden bool) bool {
base := path.Base(name)
if !hidden && strings.HasPrefix(base, ".") {
return false
}
// Office's lock files, LibreOffice's, editors' backups.
if strings.HasPrefix(base, "~$") || strings.HasPrefix(base, ".~lock.") || strings.HasSuffix(base, "~") ||
base == "Thumbs.db" || base == "desktop.ini" || base == ".DS_Store" {
return false
}
for _, re := range exc {
if re.MatchString(name) || re.MatchString(base) {
return false
}
}
if len(inc) > 0 {
for _, re := range inc {
if re.MatchString(name) || re.MatchString(base) {
return true
}
}
return false
}
return extract.Known(base)
}
// globs compiles glob patterns: "*" within a name, "**" across directories,
// "?" one character; case-insensitive, as names are on Windows and macOS.
func globs(patterns []string) ([]*regexp.Regexp, error) {
var out []*regexp.Regexp
for _, p := range patterns {
for _, one := range strings.Split(p, ",") {
one = strings.TrimSpace(filepath.ToSlash(one))
if one == "" {
continue
}
var b strings.Builder
b.WriteString("(?i)^")
for i := 0; i < len(one); i++ {
switch c := one[i]; c {
case '*':
if i+1 < len(one) && one[i+1] == '*' {
b.WriteString(".*")
i++
if i+1 < len(one) && one[i+1] == '/' {
b.WriteString("/?")
i++
}
} else {
b.WriteString("[^/]*")
}
case '?':
b.WriteString("[^/]")
default:
b.WriteString(regexp.QuoteMeta(string(c)))
}
}
b.WriteString("$")
re, err := regexp.Compile(b.String())
if err != nil {
return nil, fmt.Errorf("pattern %q: %w", one, err)
}
out = append(out, re)
}
}
return out, nil
}
// ErrNothing is a sync that found no file to read.
var ErrNothing = errors.New("no files to read under these paths")