Skip to content

Commit 963a393

Browse files
fix(sync): use aggregate hermes archive fingerprints
Hermes archive freshness needs the state.db sync path to compare the same aggregate fingerprint it persists. Discovering through the public Hermes session lister reselected state.db and missed sibling transcripts, so state.db events could avoid real skip-cache parity.\n\nEnumerate direct transcript files for the archive snapshot and stamp archive parse results with the aggregate state.db fingerprint before writing. This keeps unchanged archive syncs comparable while still refreshing when sibling transcripts change.\n\nValidation: go test -tags "fts5" ./internal/parser ./internal/sync; go vet ./...; make nilaway
1 parent 009fc3a commit 963a393

2 files changed

Lines changed: 173 additions & 2 deletions

File tree

internal/sync/engine.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6399,6 +6399,11 @@ func (e *Engine) processHermes(
63996399
if err != nil {
64006400
return processResult{err: err}
64016401
}
6402+
for i := range results {
6403+
results[i].Session.File.Path = file.Path
6404+
results[i].Session.File.Size = info.Size()
6405+
results[i].Session.File.Mtime = info.ModTime().UnixNano()
6406+
}
64026407
return processResult{results: results, forceReplace: true}
64036408
}
64046409

@@ -6431,8 +6436,8 @@ func hermesArchiveEffectiveInfo(path string, info os.FileInfo) os.FileInfo {
64316436
}
64326437
size := info.Size()
64336438
mtime := info.ModTime().UnixNano()
6434-
for _, file := range parser.DiscoverHermesSessions(sessionsDir) {
6435-
fileInfo, err := os.Stat(file.Path)
6439+
for _, path := range hermesArchiveTranscriptFiles(sessionsDir) {
6440+
fileInfo, err := os.Stat(path)
64366441
if err != nil || fileInfo == nil || fileInfo.IsDir() {
64376442
continue
64386443
}
@@ -6444,6 +6449,29 @@ func hermesArchiveEffectiveInfo(path string, info os.FileInfo) os.FileInfo {
64446449
return fakeSnapshotInfo{fSize: size, fMtime: mtime}
64456450
}
64466451

6452+
func hermesArchiveTranscriptFiles(sessionsDir string) []string {
6453+
if sessionsDir == "" {
6454+
return nil
6455+
}
6456+
entries, err := os.ReadDir(sessionsDir)
6457+
if err != nil {
6458+
return nil
6459+
}
6460+
paths := make([]string, 0, len(entries))
6461+
for _, entry := range entries {
6462+
if entry.IsDir() {
6463+
continue
6464+
}
6465+
name := entry.Name()
6466+
if strings.HasSuffix(name, ".jsonl") ||
6467+
strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".json") {
6468+
paths = append(paths, filepath.Join(sessionsDir, name))
6469+
}
6470+
}
6471+
slices.Sort(paths)
6472+
return paths
6473+
}
6474+
64476475
func hermesArchiveSourcePaths(path string) (stateDB, sessionsDir string, ok bool) {
64486476
path = filepath.Clean(path)
64496477
switch filepath.Base(path) {
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
package sync
2+
3+
import (
4+
"database/sql"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
"time"
9+
10+
_ "github.com/mattn/go-sqlite3"
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"go.kenn.io/agentsview/internal/dbtest"
15+
"go.kenn.io/agentsview/internal/parser"
16+
)
17+
18+
func TestHermesArchiveEffectiveInfoIncludesDirectTranscripts(t *testing.T) {
19+
root := t.TempDir()
20+
stateDB := writeHermesArchiveStateDB(t, root)
21+
transcriptPath := filepath.Join(root, "sessions", "extra.jsonl")
22+
require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755))
23+
require.NoError(t, os.WriteFile(transcriptPath, []byte("{}\n{}\n"), 0o644))
24+
25+
stateInfo, err := os.Stat(stateDB)
26+
require.NoError(t, err)
27+
transcriptInfo, err := os.Stat(transcriptPath)
28+
require.NoError(t, err)
29+
transcriptTime := time.Now().Add(2 * time.Second).Truncate(time.Second)
30+
require.NoError(t, os.Chtimes(transcriptPath, transcriptTime, transcriptTime))
31+
32+
got := hermesArchiveEffectiveInfo(stateDB, stateInfo)
33+
34+
assert.Equal(t, stateInfo.Size()+transcriptInfo.Size(), got.Size())
35+
assert.Equal(t, transcriptTime.UnixNano(), got.ModTime().UnixNano())
36+
}
37+
38+
func TestProcessHermesArchivePersistsAggregateFingerprint(t *testing.T) {
39+
root := t.TempDir()
40+
stateDB := writeHermesArchiveStateDB(t, root)
41+
transcriptPath := filepath.Join(root, "sessions", "extra.jsonl")
42+
require.NoError(t, os.MkdirAll(filepath.Dir(transcriptPath), 0o755))
43+
require.NoError(t, os.WriteFile(
44+
transcriptPath,
45+
[]byte(
46+
`{"role":"session_meta","platform":"cli","timestamp":"2026-05-14T10:00:00.000000"}`+"\n"+
47+
`{"role":"user","content":"new transcript","timestamp":"2026-05-14T10:01:00.000000"}`+"\n",
48+
),
49+
0o644,
50+
))
51+
52+
stateInfo, err := os.Stat(stateDB)
53+
require.NoError(t, err)
54+
effectiveInfo := hermesArchiveEffectiveInfo(stateDB, stateInfo)
55+
engine := NewEngine(dbtest.OpenTestDB(t), EngineConfig{
56+
AgentDirs: map[parser.AgentType][]string{
57+
parser.AgentHermes: {filepath.Join(root, "sessions")},
58+
},
59+
Machine: "local",
60+
})
61+
62+
res := engine.processHermes(parser.DiscoveredFile{
63+
Path: stateDB,
64+
Agent: parser.AgentHermes,
65+
}, stateInfo)
66+
67+
require.NoError(t, res.err)
68+
require.NotEmpty(t, res.results)
69+
for _, result := range res.results {
70+
assert.Equal(t, stateDB, result.Session.File.Path)
71+
assert.Equal(t, effectiveInfo.Size(), result.Session.File.Size)
72+
assert.Equal(t, effectiveInfo.ModTime().UnixNano(), result.Session.File.Mtime)
73+
}
74+
}
75+
76+
func writeHermesArchiveStateDB(t *testing.T, root string) string {
77+
t.Helper()
78+
stateDB := filepath.Join(root, "state.db")
79+
conn, err := sql.Open("sqlite3", stateDB)
80+
require.NoError(t, err)
81+
t.Cleanup(func() { _ = conn.Close() })
82+
83+
_, err = conn.Exec(`
84+
CREATE TABLE sessions (
85+
id TEXT PRIMARY KEY,
86+
source TEXT NOT NULL,
87+
user_id TEXT,
88+
model TEXT,
89+
model_config TEXT,
90+
system_prompt TEXT,
91+
parent_session_id TEXT,
92+
started_at REAL NOT NULL,
93+
ended_at REAL,
94+
end_reason TEXT,
95+
message_count INTEGER DEFAULT 0,
96+
tool_call_count INTEGER DEFAULT 0,
97+
input_tokens INTEGER DEFAULT 0,
98+
output_tokens INTEGER DEFAULT 0,
99+
cache_read_tokens INTEGER DEFAULT 0,
100+
cache_write_tokens INTEGER DEFAULT 0,
101+
reasoning_tokens INTEGER DEFAULT 0,
102+
billing_provider TEXT,
103+
billing_base_url TEXT,
104+
billing_mode TEXT,
105+
estimated_cost_usd REAL,
106+
actual_cost_usd REAL,
107+
cost_status TEXT,
108+
cost_source TEXT,
109+
pricing_version TEXT,
110+
title TEXT,
111+
api_call_count INTEGER DEFAULT 0
112+
);
113+
CREATE TABLE messages (
114+
id INTEGER PRIMARY KEY AUTOINCREMENT,
115+
session_id TEXT NOT NULL,
116+
role TEXT NOT NULL,
117+
content TEXT,
118+
tool_call_id TEXT,
119+
tool_calls TEXT,
120+
tool_name TEXT,
121+
timestamp REAL NOT NULL,
122+
token_count INTEGER,
123+
finish_reason TEXT,
124+
reasoning TEXT,
125+
reasoning_content TEXT,
126+
reasoning_details TEXT,
127+
codex_reasoning_items TEXT,
128+
codex_message_items TEXT
129+
);
130+
INSERT INTO sessions (
131+
id, source, model, started_at, ended_at, message_count
132+
) VALUES (
133+
'child', 'discord', 'gpt-5.4', 1778767200.0, 1778767800.0, 1
134+
);
135+
INSERT INTO messages (
136+
session_id, role, content, timestamp
137+
) VALUES (
138+
'child', 'user', 'state db message', 1778767210.0
139+
);
140+
`)
141+
require.NoError(t, err)
142+
return stateDB
143+
}

0 commit comments

Comments
 (0)