Skip to content

Commit 6234eb6

Browse files
authored
feat(parser): add Shelley (exe.dev) agent support (#718)
Adds first-class support for **Shelley**, the exe.dev / boldsoftware coding agent ([github.com/boldsoftware/shelley](https://github.com/boldsoftware/shelley)), so its sessions appear alongside every other agent. ## Storage model Shelley stores all conversations in a single SQLite DB at `~/.config/shelley/shelley.db` (`conversations` + `messages` tables). This is structurally identical to Zed, so the parser and sync wiring mirror the Zed single-DB pattern: a virtual source path (`shelley.db#<conversationID>`), classification before the `pathExists` guard, a WAL/SHM composite mtime, per-session `forceReplace` on parse, and a `SourceMtime` branch for the live per-session watcher. No `ResyncAll` `oldFileSessions` accounting is needed (that path is specific to the OpenCode-format storage agents) — Shelley re-parses fresh from the present DB, and vanished conversations are preserved via the generic orphan-copy. ## Extraction `messages.llm_data` is a serialized `llm.Message` with PascalCase keys and integer enums. The content `Type` integers start at 2 (text=2, thinking=3, tool_use=5, tool_result=6) because `ContentType` shares an `iota` const block with `MessageRole` and `iota` does not reset between the two groups; this was verified against a real `shelley.db`. Tool results are stored as `user`-role messages (Anthropic-style) and pair into the originating tool call. All generations are included, ordered by `sequence_id` (monotonic and unique per conversation), so a context-reset / compaction boundary never hides earlier history. ## Tokens & cost `usage_data` already uses the canonical Anthropic token keys, so the raw blob is stored verbatim and cost is catalog-priced. Token usage is also captured on errored assistant turns (stored as `type="error"`). The exact gateway `cost_usd` is present in the payload but is currently 0 from the exe.dev gateway; capturing it without double-counting the catalog-priced per-message tokens is left as a follow-up. ## Where to look - `internal/parser/shelley.go` — parser, virtual-path / read-only store helpers, content and token extraction. - `internal/sync/engine.go` — the Shelley sites mirroring Zed (classify, composite mtime, `processShelley`, dispatch, `SourceMtime`, cache-skip). - `internal/parser/types.go` — the `AgentShelley` registry entry. ## Limitations - Exact `cost_usd` capture is deferred (see above); standard gateway models are priced correctly by the catalog. - Validated against real `shelley.db` data; tool-name categories cover the current Shelley tool set (`bash`, `patch`, `keyword_search`, `browser*`, `subagent`, …). Co-authored-by: Matthew Jacobs <mjacobs@users.noreply.github.com>
1 parent 546db04 commit 6234eb6

21 files changed

Lines changed: 3004 additions & 54 deletions

frontend/src/lib/components/settings/AgentDirSettings.svelte

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
piebald: "Piebald",
2525
antigravity: "Antigravity",
2626
"antigravity-cli": "Antigravity CLI",
27+
shelley: "Shelley",
2728
};
2829
</script>
2930

internal/db/messages.go

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1175,11 +1175,10 @@ func (db *DB) MessageTokenFingerprint(sessionID string) (string, error) {
11751175

11761176
// MessageContentHashFingerprint returns an exact ordered fingerprint
11771177
// of per-message body content: ordinal, the stored content_length
1178-
// column, and a SHA-256 over the sanitized content. The parse-diff
1179-
// comparator uses it instead of the aggregate
1180-
// MessageContentFingerprint (sum/max/min of content_length, kept for
1181-
// the PG push fast-path), which cannot see equal-length body rewrites
1182-
// or per-message length changes whose aggregates collide.
1178+
// column, and a SHA-256 over the sanitized content. Parse-diff and PG
1179+
// push use it alongside the aggregate MessageContentFingerprint
1180+
// (sum/max/min of content_length), which cannot see equal-length body
1181+
// rewrites or per-message length changes whose aggregates collide.
11831182
func (db *DB) MessageContentHashFingerprint(sessionID string) (string, error) {
11841183
rows, err := db.getReader().Query(
11851184
`SELECT ordinal, content, content_length
@@ -1221,6 +1220,20 @@ func (db *DB) MessageContentHashFingerprint(sessionID string) (string, error) {
12211220
// (selectMessageCols coalesces the same way); without it a single
12221221
// imported NULL row would error here and abort the whole parse-diff run.
12231222
func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
1223+
return db.MessageRoleTimeFingerprintWithTimestampNormalizer(
1224+
sessionID, nil,
1225+
)
1226+
}
1227+
1228+
// MessageRoleTimeFingerprintWithTimestampNormalizer returns the same
1229+
// fingerprint as MessageRoleTimeFingerprint after applying normalizeTimestamp
1230+
// to each timestamp value. It lets callers compare against stores that preserve
1231+
// a different timestamp representation while keeping the query and field
1232+
// ordering identical to the raw parse-diff fingerprint.
1233+
func (db *DB) MessageRoleTimeFingerprintWithTimestampNormalizer(
1234+
sessionID string,
1235+
normalizeTimestamp func(string) string,
1236+
) (string, error) {
12241237
rows, err := db.getReader().Query(
12251238
`SELECT ordinal, role, COALESCE(timestamp, '')
12261239
FROM messages
@@ -1241,6 +1254,9 @@ func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
12411254
return "", err
12421255
}
12431256
role = SanitizeUTF8(role)
1257+
if normalizeTimestamp != nil {
1258+
timestamp = normalizeTimestamp(timestamp)
1259+
}
12441260
fmt.Fprintf(&b, "%d|%d:%s|%d:%s;",
12451261
ordinal, len(role), role, len(timestamp), timestamp)
12461262
}
@@ -1253,7 +1269,8 @@ func (db *DB) MessageRoleTimeFingerprint(sessionID string) (string, error) {
12531269
// has_tool_use, and a SHA-256 over the sanitized thinking_text. The
12541270
// parse-diff comparator uses it as a tier-1 fast path so a parser change
12551271
// confined to these columns still triggers the tier-2 row comparison.
1256-
// Not used by the PG push fast-path.
1272+
// PG push uses it with a PostgreSQL-side twin to avoid skipping
1273+
// metadata-only rewrites.
12571274
func (db *DB) MessageFlagsFingerprint(sessionID string) (string, error) {
12581275
rows, err := db.getReader().Query(
12591276
`SELECT ordinal, is_system, has_thinking, has_tool_use,

internal/db/sessions.go

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1323,20 +1323,49 @@ func (db *DB) GetSessionMessageCount(
13231323
return count, true
13241324
}
13251325

1326-
// GetSessionVersion returns the message count and file mtime
1327-
// for change detection in SSE watchers.
1326+
// SessionVersionMarker returns a compact marker for one or more
1327+
// version fields. Inputs are length-framed so adjacent fields cannot
1328+
// collide by concatenation.
1329+
func SessionVersionMarker(parts ...string) int64 {
1330+
const (
1331+
offset64 = uint64(14695981039346656037)
1332+
prime64 = uint64(1099511628211)
1333+
)
1334+
h := offset64
1335+
write := func(s string) {
1336+
for _, b := range []byte(s) {
1337+
h ^= uint64(b)
1338+
h *= prime64
1339+
}
1340+
}
1341+
for _, part := range parts {
1342+
write(fmt.Sprintf("%d:", len(part)))
1343+
write(part)
1344+
}
1345+
return int64(h)
1346+
}
1347+
1348+
// GetSessionVersion returns the message count and a compact version
1349+
// marker for change detection in SSE watchers.
13281350
func (db *DB) GetSessionVersion(
13291351
id string,
1330-
) (count int, fileMtime int64, ok bool) {
1352+
) (count int, version int64, ok bool) {
1353+
var fileMtime int64
1354+
var fileHash, localModifiedAt string
13311355
err := db.getReader().QueryRow(
1332-
"SELECT message_count, COALESCE(file_mtime, 0)"+
1356+
"SELECT message_count, COALESCE(file_mtime, 0),"+
1357+
" COALESCE(file_hash, ''), COALESCE(local_modified_at, '')"+
13331358
" FROM sessions WHERE id = ?",
13341359
id,
1335-
).Scan(&count, &fileMtime)
1360+
).Scan(&count, &fileMtime, &fileHash, &localModifiedAt)
13361361
if err != nil {
13371362
return 0, 0, false
13381363
}
1339-
return count, fileMtime, true
1364+
return count, SessionVersionMarker(
1365+
fmt.Sprintf("%d", fileMtime),
1366+
fileHash,
1367+
localModifiedAt,
1368+
), true
13401369
}
13411370

13421371
// IncrementalInfo holds the data needed for incremental
@@ -1517,6 +1546,25 @@ func (db *DB) GetFileInfoByPath(
15171546
return s.Int64, m.Int64, true
15181547
}
15191548

1549+
// GetFileHashByPath returns the stored file_hash for the session
1550+
// matching file_path, preferring the most recently modified row.
1551+
// The bool is false when no row exists or the column is NULL. Used
1552+
// by the Shelley skip to compare a per-conversation content
1553+
// fingerprint alongside file_mtime.
1554+
func (db *DB) GetFileHashByPath(path string) (hash string, ok bool) {
1555+
var h sql.NullString
1556+
err := db.getReader().QueryRow(
1557+
"SELECT file_hash FROM sessions"+
1558+
" WHERE file_path = ?"+
1559+
" ORDER BY file_mtime DESC LIMIT 1",
1560+
path,
1561+
).Scan(&h)
1562+
if err != nil {
1563+
return "", false
1564+
}
1565+
return h.String, h.Valid
1566+
}
1567+
15201568
// GetDataVersionByPath returns the minimum data_version for
15211569
// sessions matching a file_path. Returns 0 when no session
15221570
// exists for the path.

internal/db/store.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ type Store interface {
4545
SecretFindingSource(ctx context.Context, f SecretFinding) (string, bool, error)
4646

4747
// SSE change detection.
48-
GetSessionVersion(id string) (count int, fileMtime int64, ok bool)
48+
GetSessionVersion(id string) (count int, version int64, ok bool)
4949

5050
// Metadata.
5151
GetStats(ctx context.Context, excludeOneShot, excludeAutomated bool) (Stats, error)

internal/duckdb/store.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -399,21 +399,31 @@ func (s *Store) GetChildSessions(ctx context.Context, parentID string) ([]db.Ses
399399

400400
func (s *Store) GetSessionVersion(id string) (int, int64, bool) {
401401
var count int
402+
var fileMtime sql.NullInt64
403+
var fileHash sql.NullString
402404
var updated any
403405
err := s.duck.QueryRow(
404-
`SELECT message_count, COALESCE(local_modified_at, ended_at, started_at, created_at)
406+
`SELECT message_count, file_mtime, file_hash,
407+
COALESCE(local_modified_at, ended_at, started_at, created_at)
405408
FROM sessions WHERE id = ?`,
406409
id,
407-
).Scan(&count, &updated)
410+
).Scan(&count, &fileMtime, &fileHash, &updated)
408411
if err != nil {
409412
return 0, 0, false
410413
}
411-
formatted := formatDBTime(updated)
412-
var h int64
413-
for _, c := range formatted {
414-
h = h*31 + int64(c)
414+
fileMtimePart := ""
415+
if fileMtime.Valid {
416+
fileMtimePart = fmt.Sprintf("%d", fileMtime.Int64)
415417
}
416-
return count, h, true
418+
fileHashPart := ""
419+
if fileHash.Valid {
420+
fileHashPart = fileHash.String
421+
}
422+
return count, db.SessionVersionMarker(
423+
fileMtimePart,
424+
fileHashPart,
425+
formatDBTime(updated),
426+
), true
417427
}
418428

419429
func (s *Store) GetStats(ctx context.Context, excludeOneShot, excludeAutomated bool) (db.Stats, error) {

0 commit comments

Comments
 (0)