f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
770 lines
20 KiB
Go
770 lines
20 KiB
Go
package parser
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"hash"
|
|
"io"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
var _ Provider = (*hermesProvider)(nil)
|
|
|
|
type hermesProviderFactory struct {
|
|
def AgentDef
|
|
}
|
|
|
|
func newHermesProviderFactory(def AgentDef) ProviderFactory {
|
|
return hermesProviderFactory{def: cloneAgentDef(def)}
|
|
}
|
|
|
|
func (f hermesProviderFactory) Definition() AgentDef {
|
|
return cloneAgentDef(f.def)
|
|
}
|
|
|
|
func (f hermesProviderFactory) Capabilities() Capabilities {
|
|
return hermesProviderCapabilities()
|
|
}
|
|
|
|
func (f hermesProviderFactory) NewProvider(cfg ProviderConfig) Provider {
|
|
cfg = cfg.Clone()
|
|
return &hermesProvider{
|
|
ProviderBase: ProviderBase{
|
|
Def: cloneAgentDef(f.def),
|
|
Caps: hermesProviderCapabilities(),
|
|
Config: cfg,
|
|
},
|
|
sources: newHermesSourceSet(cfg.Roots),
|
|
}
|
|
}
|
|
|
|
type hermesProvider struct {
|
|
ProviderBase
|
|
sources hermesSourceSet
|
|
}
|
|
|
|
func (p *hermesProvider) Discover(ctx context.Context) ([]SourceRef, error) {
|
|
return p.sources.Discover(ctx)
|
|
}
|
|
|
|
func (p *hermesProvider) WatchPlan(ctx context.Context) (WatchPlan, error) {
|
|
return p.sources.WatchPlan(ctx)
|
|
}
|
|
|
|
func (p *hermesProvider) SourcesForChangedPath(
|
|
ctx context.Context,
|
|
req ChangedPathRequest,
|
|
) ([]SourceRef, error) {
|
|
return p.sources.SourcesForChangedPath(ctx, req)
|
|
}
|
|
|
|
func (p *hermesProvider) FindSource(
|
|
ctx context.Context,
|
|
req FindSourceRequest,
|
|
) (SourceRef, bool, error) {
|
|
req = ProviderFindRequestWithRawSessionID(p.Def, req)
|
|
return p.sources.FindSource(ctx, req)
|
|
}
|
|
|
|
func (p *hermesProvider) Fingerprint(
|
|
ctx context.Context,
|
|
source SourceRef,
|
|
) (SourceFingerprint, error) {
|
|
return p.sources.Fingerprint(ctx, source)
|
|
}
|
|
|
|
func WriteHermesSessionJSONL(
|
|
w io.Writer, storedPath string, roots []string, rawSessionID string,
|
|
) error {
|
|
var storedStateErr error
|
|
if path := ResolveSourceFilePath(storedPath); filepath.Base(path) == "state.db" {
|
|
if _, err := os.Stat(path); err == nil {
|
|
err = writeHermesStateSessionJSONL(w, path, rawSessionID)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
var lookupErr hermesStateLookupError
|
|
if !errors.Is(err, os.ErrNotExist) &&
|
|
!errors.As(err, &lookupErr) {
|
|
return err
|
|
}
|
|
storedStateErr = err
|
|
}
|
|
if transcript := findHermesSourceFile(
|
|
filepath.Join(filepath.Dir(path), "sessions"),
|
|
rawSessionID,
|
|
); transcript != "" {
|
|
return copyHermesTranscriptFile(w, transcript)
|
|
}
|
|
}
|
|
provider, ok := NewProvider(AgentHermes, ProviderConfig{Roots: roots})
|
|
if !ok {
|
|
return fmt.Errorf("hermes provider unavailable")
|
|
}
|
|
hp, ok := provider.(*hermesProvider)
|
|
if !ok {
|
|
return fmt.Errorf("hermes provider unavailable")
|
|
}
|
|
source, found, err := hp.FindSource(
|
|
context.Background(),
|
|
FindSourceRequest{RawSessionID: rawSessionID},
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
if storedStateErr != nil && !errors.Is(storedStateErr, os.ErrNotExist) {
|
|
return storedStateErr
|
|
}
|
|
return fmt.Errorf(
|
|
"hermes session %s source not found: %w",
|
|
rawSessionID, os.ErrNotExist,
|
|
)
|
|
}
|
|
path, ok := hp.sources.pathFromSource(source)
|
|
if !ok {
|
|
return fmt.Errorf("hermes source path unavailable")
|
|
}
|
|
if filepath.Base(path) == "state.db" {
|
|
return writeHermesStateSessionJSONL(w, path, rawSessionID)
|
|
}
|
|
return copyHermesTranscriptFile(w, path)
|
|
}
|
|
|
|
func (p *hermesProvider) Parse(
|
|
ctx context.Context,
|
|
req ParseRequest,
|
|
) (ParseOutcome, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
path, ok := p.sources.pathFromSource(req.Source)
|
|
if !ok {
|
|
return ParseOutcome{}, fmt.Errorf("hermes source path unavailable")
|
|
}
|
|
machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine)
|
|
if filepath.Base(path) == "state.db" {
|
|
results, err := p.parseArchive(path, req.Source.ProjectHint, machine)
|
|
if err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
// Mirror the legacy engine's stampHermesArchiveResults: every archive
|
|
// session's stored file identity is the state.db path with the
|
|
// aggregate (state.db plus transcripts) size and mtime, so a
|
|
// transcript-only change still refreshes the archive's freshness.
|
|
size, mtime := hermesArchiveEffectiveFileInfo(path)
|
|
out := make([]ParseResultOutcome, 0, len(results))
|
|
for i := range results {
|
|
results[i].Session.File.Path = path
|
|
results[i].Session.File.Size = size
|
|
results[i].Session.File.Mtime = mtime
|
|
out = append(out, ParseResultOutcome{
|
|
Result: results[i],
|
|
DataVersion: DataVersionCurrent,
|
|
})
|
|
}
|
|
return ParseOutcome{
|
|
Results: out,
|
|
ResultSetComplete: true,
|
|
ForceReplace: true,
|
|
}, nil
|
|
}
|
|
|
|
sess, msgs, err := p.parseSession(path, req.Source.ProjectHint, machine)
|
|
if err != nil {
|
|
return ParseOutcome{}, err
|
|
}
|
|
if sess == nil {
|
|
return ParseOutcome{
|
|
ResultSetComplete: true,
|
|
SkipReason: SkipNoSession,
|
|
}, nil
|
|
}
|
|
if req.Fingerprint.Hash != "" {
|
|
sess.File.Hash = req.Fingerprint.Hash
|
|
}
|
|
return ParseOutcome{
|
|
Results: []ParseResultOutcome{{
|
|
Result: ParseResult{
|
|
Session: *sess,
|
|
Messages: msgs,
|
|
},
|
|
DataVersion: DataVersionCurrent,
|
|
}},
|
|
ResultSetComplete: true,
|
|
}, nil
|
|
}
|
|
|
|
type hermesSource struct {
|
|
Root string
|
|
Path string
|
|
}
|
|
|
|
type hermesSourceSet struct {
|
|
roots []string
|
|
}
|
|
|
|
func newHermesSourceSet(roots []string) hermesSourceSet {
|
|
return hermesSourceSet{roots: cleanJSONLRoots(roots)}
|
|
}
|
|
|
|
func (s hermesSourceSet) Discover(ctx context.Context) ([]SourceRef, error) {
|
|
var sources []SourceRef
|
|
seen := make(map[string]struct{})
|
|
for _, root := range s.roots {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
for _, file := range discoverHermesSessions(root) {
|
|
source, ok := s.sourceRef(root, file.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
addJSONLSource(source, &sources, seen)
|
|
}
|
|
}
|
|
sortJSONLSources(sources)
|
|
return sources, nil
|
|
}
|
|
|
|
func (s hermesSourceSet) WatchPlan(context.Context) (WatchPlan, error) {
|
|
roots := make([]WatchRoot, 0, len(s.roots))
|
|
for _, root := range s.roots {
|
|
roots = append(roots, hermesWatchRoots(root)...)
|
|
}
|
|
return WatchPlan{Roots: roots}, nil
|
|
}
|
|
|
|
func (s hermesSourceSet) SourcesForChangedPath(
|
|
ctx context.Context,
|
|
req ChangedPathRequest,
|
|
) ([]SourceRef, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
allowMissing := jsonlMissingPathFallbackAllowed(req)
|
|
if req.WatchRoot != "" {
|
|
watchRoot := filepath.Clean(req.WatchRoot)
|
|
for _, root := range s.roots {
|
|
if !hermesWatchRootMatches(root, watchRoot) {
|
|
continue
|
|
}
|
|
source, ok := s.sourceForChangedPath(root, req.Path, allowMissing)
|
|
if ok {
|
|
return []SourceRef{source}, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
for _, root := range s.roots {
|
|
source, ok := s.sourceForChangedPath(root, req.Path, allowMissing)
|
|
if ok {
|
|
return []SourceRef{source}, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (s hermesSourceSet) FindSource(
|
|
ctx context.Context,
|
|
req FindSourceRequest,
|
|
) (SourceRef, bool, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return SourceRef{}, false, err
|
|
}
|
|
for _, path := range []string{req.StoredFilePath, req.FingerprintKey} {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
for _, root := range s.roots {
|
|
if source, ok := s.sourceForPath(root, path); ok {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
}
|
|
if req.RawSessionID == "" {
|
|
return SourceRef{}, false, nil
|
|
}
|
|
for _, root := range s.roots {
|
|
if stateDB, _, ok := hermesStatePaths(root); ok &&
|
|
IsValidSessionID(req.RawSessionID) {
|
|
found, err := hermesStateDBHasSession(stateDB, req.RawSessionID)
|
|
switch {
|
|
case err != nil:
|
|
// Mirror parseArchive: an unreadable or schema-incompatible
|
|
// state.db falls back to transcripts rather than aborting the
|
|
// lookup, so a valid transcript session next to a bad state.db
|
|
// stays resolvable for resync.
|
|
log.Printf(
|
|
"hermes: state db lookup failed for %s: %v; "+
|
|
"falling back to transcripts", stateDB, err,
|
|
)
|
|
case !found:
|
|
default:
|
|
if source, ok := s.sourceRef(root, stateDB); ok {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
}
|
|
transcriptRoot := hermesTranscriptRoot(root)
|
|
path := findHermesSourceFile(transcriptRoot, req.RawSessionID)
|
|
if path == "" {
|
|
continue
|
|
}
|
|
if source, ok := s.sourceRef(root, path); ok {
|
|
return source, true, nil
|
|
}
|
|
}
|
|
return SourceRef{}, false, nil
|
|
}
|
|
|
|
type hermesStateLookupError struct {
|
|
err error
|
|
}
|
|
|
|
func (e hermesStateLookupError) Error() string {
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e hermesStateLookupError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func hermesStateDBHasSession(stateDB string, rawID string) (bool, error) {
|
|
conn, err := sql.Open("sqlite3", "file:"+sqliteURIPath(stateDB)+"?mode=ro")
|
|
if err != nil {
|
|
return false, fmt.Errorf("open hermes state db: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
var found int
|
|
err = conn.QueryRow(
|
|
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1",
|
|
rawID,
|
|
).Scan(&found)
|
|
if err == nil {
|
|
return true, nil
|
|
}
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("query hermes session %s: %w", rawID, err)
|
|
}
|
|
|
|
func (s hermesSourceSet) Fingerprint(
|
|
ctx context.Context,
|
|
source SourceRef,
|
|
) (SourceFingerprint, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
path, ok := s.pathFromSource(source)
|
|
if !ok {
|
|
return SourceFingerprint{}, fmt.Errorf("hermes source path unavailable")
|
|
}
|
|
if filepath.Base(path) == "state.db" {
|
|
return hermesArchiveFingerprint(source, path)
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
if info.IsDir() {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", path)
|
|
}
|
|
hash, err := hashJSONLSourceFile(path)
|
|
if err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
return SourceFingerprint{
|
|
Key: firstNonEmptyJSONLString(source.FingerprintKey, source.Key, path),
|
|
Size: info.Size(),
|
|
MTimeNS: info.ModTime().UnixNano(),
|
|
Hash: hash,
|
|
}, nil
|
|
}
|
|
|
|
func (s hermesSourceSet) pathFromSource(source SourceRef) (string, bool) {
|
|
switch src := source.Opaque.(type) {
|
|
case hermesSource:
|
|
return src.Path, src.Path != ""
|
|
case *hermesSource:
|
|
if src != nil && src.Path != "" {
|
|
return src.Path, true
|
|
}
|
|
}
|
|
for _, candidate := range []string{
|
|
source.DisplayPath,
|
|
source.FingerprintKey,
|
|
source.Key,
|
|
} {
|
|
for _, root := range s.roots {
|
|
if ref, ok := s.sourceForPath(root, candidate); ok {
|
|
src := ref.Opaque.(hermesSource)
|
|
return src.Path, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func (s hermesSourceSet) sourceForPath(root, path string) (SourceRef, bool) {
|
|
return s.sourceForChangedPath(root, path, false)
|
|
}
|
|
|
|
func (s hermesSourceSet) sourceForChangedPath(
|
|
root,
|
|
path string,
|
|
allowMissing bool,
|
|
) (SourceRef, bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
if stateDB, sessionsDir, ok := hermesStatePaths(root); ok {
|
|
if samePath(path, stateDB) || hermesPathInTranscriptDir(sessionsDir, path) {
|
|
return hermesArchiveSourceRef(root, stateDB)
|
|
}
|
|
return SourceRef{}, false
|
|
}
|
|
if allowMissing {
|
|
if stateDB, sessionsDir, ok := hermesArchivePathsForEvent(root, path); ok &&
|
|
(samePath(path, stateDB) || hermesPathInTranscriptDir(sessionsDir, path)) {
|
|
return hermesArchiveSourceRef(root, stateDB)
|
|
}
|
|
transcriptRoot := hermesTranscriptRoot(root)
|
|
if hermesPathInTranscriptDir(transcriptRoot, path) {
|
|
return hermesTranscriptSourceRef(root, path)
|
|
}
|
|
}
|
|
return s.sourceRef(root, path)
|
|
}
|
|
|
|
func (s hermesSourceSet) sourceRef(root, path string) (SourceRef, bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
if stateDB, _, ok := hermesStatePaths(root); ok && samePath(path, stateDB) {
|
|
return hermesArchiveSourceRef(root, stateDB)
|
|
}
|
|
transcriptRoot := hermesTranscriptRoot(root)
|
|
if !hermesPathInTranscriptDir(transcriptRoot, path) || !IsRegularFile(path) {
|
|
return SourceRef{}, false
|
|
}
|
|
return hermesTranscriptSourceRef(root, path)
|
|
}
|
|
|
|
func hermesArchiveSourceRef(root, stateDB string) (SourceRef, bool) {
|
|
root = filepath.Clean(root)
|
|
stateDB = filepath.Clean(stateDB)
|
|
return SourceRef{
|
|
Provider: AgentHermes,
|
|
Key: stateDB,
|
|
DisplayPath: stateDB,
|
|
FingerprintKey: stateDB,
|
|
Opaque: hermesSource{
|
|
Root: root,
|
|
Path: stateDB,
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func hermesTranscriptSourceRef(root, path string) (SourceRef, bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
return SourceRef{
|
|
Provider: AgentHermes,
|
|
Key: path,
|
|
DisplayPath: path,
|
|
FingerprintKey: path,
|
|
Opaque: hermesSource{
|
|
Root: root,
|
|
Path: path,
|
|
},
|
|
}, true
|
|
}
|
|
|
|
func hermesWatchRoots(root string) []WatchRoot {
|
|
root = filepath.Clean(root)
|
|
if stateDB, sessionsDir, ok := hermesArchiveRootPaths(root); ok {
|
|
watchRoots := []WatchRoot{{
|
|
Path: filepath.Dir(stateDB),
|
|
Recursive: false,
|
|
IncludeGlobs: []string{"state.db"},
|
|
DebounceKey: string(AgentHermes) + ":archive:" + root,
|
|
}}
|
|
watchRoots = append(watchRoots, WatchRoot{
|
|
Path: sessionsDir,
|
|
Recursive: true,
|
|
IncludeGlobs: []string{"*.jsonl", "session_*.json"},
|
|
DebounceKey: string(AgentHermes) + ":sessions:" + root,
|
|
})
|
|
return watchRoots
|
|
}
|
|
return []WatchRoot{{
|
|
Path: root,
|
|
Recursive: true,
|
|
IncludeGlobs: []string{"state.db", "*.jsonl", "session_*.json"},
|
|
DebounceKey: string(AgentHermes) + ":sessions:" + root,
|
|
}}
|
|
}
|
|
|
|
func ResolveHermesWatchRoots(root string) []string {
|
|
root = filepath.Clean(root)
|
|
if _, sessionsDir, ok := hermesArchiveRootPaths(root); ok {
|
|
return []string{sessionsDir}
|
|
}
|
|
return []string{root}
|
|
}
|
|
|
|
func ResolveHermesShallowWatchRoots(root string) []string {
|
|
root = filepath.Clean(root)
|
|
if stateDB, _, ok := hermesArchiveRootPaths(root); ok {
|
|
return []string{filepath.Dir(stateDB)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hermesWatchRootMatches(root, watchRoot string) bool {
|
|
root = filepath.Clean(root)
|
|
watchRoot = filepath.Clean(watchRoot)
|
|
if samePath(root, watchRoot) {
|
|
return true
|
|
}
|
|
if stateDB, sessionsDir, ok := hermesArchiveRootPaths(root); ok {
|
|
return samePath(watchRoot, filepath.Dir(stateDB)) ||
|
|
samePath(watchRoot, sessionsDir)
|
|
}
|
|
switch filepath.Base(root) {
|
|
case "state.db":
|
|
return samePath(watchRoot, filepath.Dir(root)) ||
|
|
samePath(watchRoot, filepath.Join(filepath.Dir(root), "sessions"))
|
|
case "sessions":
|
|
return samePath(watchRoot, filepath.Dir(root))
|
|
default:
|
|
return samePath(watchRoot, filepath.Join(root, "sessions"))
|
|
}
|
|
}
|
|
|
|
func hermesArchivePathsForEvent(root, path string) (stateDB, sessionsDir string, ok bool) {
|
|
root = filepath.Clean(root)
|
|
path = filepath.Clean(path)
|
|
switch {
|
|
case filepath.Base(root) == "state.db":
|
|
stateDB = root
|
|
sessionsDir = filepath.Join(filepath.Dir(root), "sessions")
|
|
case filepath.Base(root) == "sessions":
|
|
stateDB = filepath.Join(filepath.Dir(root), "state.db")
|
|
sessionsDir = root
|
|
case samePath(path, filepath.Join(root, "state.db")) ||
|
|
IsRegularFile(filepath.Join(root, "state.db")):
|
|
stateDB = filepath.Join(root, "state.db")
|
|
sessionsDir = filepath.Join(root, "sessions")
|
|
default:
|
|
return "", "", false
|
|
}
|
|
return stateDB, sessionsDir, true
|
|
}
|
|
|
|
func hermesArchiveRootPaths(root string) (stateDB, sessionsDir string, ok bool) {
|
|
root = filepath.Clean(root)
|
|
if stateDB, sessionsDir, ok := hermesStatePaths(root); ok {
|
|
return stateDB, sessionsDir, true
|
|
}
|
|
switch filepath.Base(root) {
|
|
case "state.db":
|
|
return root, filepath.Join(filepath.Dir(root), "sessions"), true
|
|
case "sessions":
|
|
return filepath.Join(filepath.Dir(root), "state.db"), root, true
|
|
default:
|
|
stateDB = filepath.Join(root, "state.db")
|
|
sessionsDir = filepath.Join(root, "sessions")
|
|
if IsRegularFile(stateDB) {
|
|
return stateDB, sessionsDir, true
|
|
}
|
|
if info, err := os.Stat(sessionsDir); err == nil && info.IsDir() {
|
|
return stateDB, sessionsDir, true
|
|
}
|
|
return "", "", false
|
|
}
|
|
}
|
|
|
|
func hermesTranscriptRoot(root string) string {
|
|
root = filepath.Clean(root)
|
|
if _, sessionsDir, ok := hermesStatePaths(root); ok {
|
|
return sessionsDir
|
|
}
|
|
childSessions := filepath.Join(root, "sessions")
|
|
if info, err := os.Stat(childSessions); err == nil && info.IsDir() {
|
|
return childSessions
|
|
}
|
|
return root
|
|
}
|
|
|
|
func hermesPathInTranscriptDir(dir, path string) bool {
|
|
dir = filepath.Clean(dir)
|
|
path = filepath.Clean(path)
|
|
if !samePath(filepath.Dir(path), dir) {
|
|
return false
|
|
}
|
|
name := filepath.Base(path)
|
|
if strings.HasSuffix(name, ".jsonl") {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(name, ".json") && strings.HasPrefix(name, "session_")
|
|
}
|
|
|
|
func hermesArchiveFingerprint(source SourceRef, stateDB string) (SourceFingerprint, error) {
|
|
stateInfo, err := os.Stat(stateDB)
|
|
if err != nil {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", stateDB, err)
|
|
}
|
|
if stateInfo.IsDir() {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: source is a directory", stateDB)
|
|
}
|
|
fingerprint := SourceFingerprint{
|
|
Key: firstNonEmptyJSONLString(
|
|
source.FingerprintKey,
|
|
source.Key,
|
|
stateDB,
|
|
),
|
|
Size: stateInfo.Size(),
|
|
MTimeNS: stateInfo.ModTime().UnixNano(),
|
|
}
|
|
h := sha256.New()
|
|
if err := addHermesFingerprintPart(h, "state", stateDB, stateInfo); err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
_, sessionsDir, _ := hermesStatePaths(stateDB)
|
|
for _, file := range discoverHermesTranscriptFiles(sessionsDir) {
|
|
info, err := os.Stat(file.Path)
|
|
if err != nil {
|
|
return SourceFingerprint{}, fmt.Errorf("stat %s: %w", file.Path, err)
|
|
}
|
|
fingerprint.Size += info.Size()
|
|
if mtime := info.ModTime().UnixNano(); mtime > fingerprint.MTimeNS {
|
|
fingerprint.MTimeNS = mtime
|
|
}
|
|
if err := addHermesFingerprintPart(h, "transcript", file.Path, info); err != nil {
|
|
return SourceFingerprint{}, err
|
|
}
|
|
}
|
|
fingerprint.Hash = fmt.Sprintf("%x", h.Sum(nil))
|
|
return fingerprint, nil
|
|
}
|
|
|
|
// hermesArchiveEffectiveFileInfo returns the aggregate size and mtime of a
|
|
// Hermes archive: the state.db plus every transcript file in its sessions
|
|
// directory. It reproduces the legacy engine's hermesArchiveEffectiveInfo so a
|
|
// transcript-only change shifts the stored archive freshness even though the
|
|
// state.db itself is unchanged. The transcript set matches the legacy
|
|
// hermesArchiveTranscriptFiles: every .jsonl and session_*.json file directly
|
|
// under the sessions directory, without the .jsonl/.json dedup used elsewhere.
|
|
func hermesArchiveEffectiveFileInfo(stateDB string) (int64, int64) {
|
|
info, err := os.Stat(stateDB)
|
|
if err != nil {
|
|
return 0, 0
|
|
}
|
|
size := info.Size()
|
|
mtime := info.ModTime().UnixNano()
|
|
_, sessionsDir, ok := hermesStatePaths(stateDB)
|
|
if !ok {
|
|
return size, mtime
|
|
}
|
|
for _, path := range hermesArchiveTranscriptFiles(sessionsDir) {
|
|
fileInfo, err := os.Stat(path)
|
|
if err != nil || fileInfo == nil || fileInfo.IsDir() {
|
|
continue
|
|
}
|
|
size += fileInfo.Size()
|
|
if fileMtime := fileInfo.ModTime().UnixNano(); fileMtime > mtime {
|
|
mtime = fileMtime
|
|
}
|
|
}
|
|
return size, mtime
|
|
}
|
|
|
|
// hermesArchiveTranscriptFiles lists every .jsonl and session_*.json file
|
|
// directly under sessionsDir, sorted by path. It mirrors the legacy engine
|
|
// helper of the same name so the provider's effective-info aggregation matches
|
|
// historical behavior exactly.
|
|
func hermesArchiveTranscriptFiles(sessionsDir string) []string {
|
|
if sessionsDir == "" {
|
|
return nil
|
|
}
|
|
entries, err := os.ReadDir(sessionsDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
paths := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
if strings.HasSuffix(name, ".jsonl") ||
|
|
strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".json") {
|
|
paths = append(paths, filepath.Join(sessionsDir, name))
|
|
}
|
|
}
|
|
sort.Strings(paths)
|
|
return paths
|
|
}
|
|
|
|
func addHermesFingerprintPart(
|
|
h hash.Hash,
|
|
label string,
|
|
path string,
|
|
info os.FileInfo,
|
|
) error {
|
|
if _, err := fmt.Fprintf(
|
|
h,
|
|
"%s\x00%s\x00%d\x00%d\x00",
|
|
label,
|
|
path,
|
|
info.Size(),
|
|
info.ModTime().UnixNano(),
|
|
); err != nil {
|
|
return err
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("open %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
if _, err := io.Copy(h, f); err != nil {
|
|
return fmt.Errorf("hash %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hermesProviderCapabilities() Capabilities {
|
|
return Capabilities{
|
|
Source: SourceCapabilities{
|
|
DiscoverSources: CapabilitySupported,
|
|
WatchSources: CapabilitySupported,
|
|
ClassifyChangedPath: CapabilitySupported,
|
|
FindSource: CapabilitySupported,
|
|
CompositeFingerprint: CapabilitySupported,
|
|
MultiSessionSource: CapabilitySupported,
|
|
PerSessionErrors: CapabilityNotApplicable,
|
|
ExcludedSessions: CapabilityNotApplicable,
|
|
ForceReplaceOnParse: CapabilitySupported,
|
|
},
|
|
Content: ContentCapabilities{
|
|
FirstMessage: CapabilitySupported,
|
|
SessionName: CapabilitySupported,
|
|
Relationships: CapabilitySupported,
|
|
Thinking: CapabilitySupported,
|
|
ToolCalls: CapabilitySupported,
|
|
ToolResults: CapabilitySupported,
|
|
AggregateUsageEvents: CapabilitySupported,
|
|
Model: CapabilitySupported,
|
|
},
|
|
}
|
|
}
|