From 42c23f9a2747e4e6abee0f00bb0ac9d0c4aa2060 Mon Sep 17 00:00:00 2001 From: Marius van Niekerk Date: Wed, 24 Jun 2026 21:34:41 -0400 Subject: [PATCH] feat(parser): migrate gptme to provider facade The facade needs at least one real provider implementation before caller migration can prove the contract. Gptme is a narrow first target because it has a single-session JSONL source layout and an existing parser path that can be wrapped without changing runtime sync dispatch. This keeps gptme source behavior explicit: the provider composes JSONLSourceSet for filesystem mechanics, filters to the legacy one-level conversation.jsonl layout, and returns complete current parse outcomes while the rest of the registry remains on legacy adapters. fix(parser): preserve gptme provider source parity The gptme provider is intended to be a no-behavior-change facade migration, so it needs to preserve the legacy source semantics before sync callers can safely move to it. Symlinked session directories, deleted source events, and persisted lookup hints are all observable through the current discovery and session lookup paths. This keeps provider-backed gptme discovery and changed-path classification compatible with those legacy expectations while leaving runtime dispatch unchanged. test(parser): opt gptme into provider shadow Gptme now has a concrete facade provider on this branch, so the migration manifest should force it through the shared shadow-compare harness instead of leaving the provider implementation additive and unexercised. Lower branch opt-ins remain inherited and later provider families stay legacy-only until their own branches introduce concrete providers. Validation: go test -tags "fts5" ./internal/parser -run TestProviderMigrationModes -count=1; go test -tags "fts5" ./internal/parser -count=1; go vet ./...; git diff --check test(sync): compare gptme shadow parity GPTMe is marked shadow-compare on this branch, so add the shared source-level migration proof beside the concrete provider. The test runs ObserveProviderSource and compares normalized provider output with ParseGptmeSession while preserving the provider-computed content hash. Validation: go fmt ./...; go test -tags "fts5" ./internal/parser ./internal/sync -count=1; go vet ./...; git diff --check; ./custom-gcl run --config .golangci.nilaway.yml ./internal/parser/... ./internal/sync/... refactor(parser): fold gptme into provider GPTMe should be a migrated provider on this branch, not a provider wrapper around exported legacy parser entrypoints. Keeping DiscoverGptmeSessions, FindGptmeSourceFile, ParseGptmeSession, and the engine processGptme path made the stack additive and left two public shapes to maintain. Move GPTMe parsing behind the concrete provider, make GPTMe provider-authoritative at this branch, remove its legacy AgentDef hooks and engine dispatch, and replace shadow-baseline tests with provider API coverage plus a guard that the legacy symbols stay gone. Validation: go test -tags "fts5" ./internal/parser ./internal/sync ./cmd/agentsview -count=1; go vet ./...; git diff --check fix(parser): thread ctx through gptme source lookups --- internal/parser/gptme.go | 54 +---- internal/parser/gptme_provider.go | 323 +++++++++++++++++++++++++ internal/parser/gptme_provider_test.go | 230 ++++++++++++++++++ internal/parser/gptme_test.go | 63 +++-- internal/parser/provider.go | 2 + internal/parser/provider_migration.go | 2 +- internal/parser/types.go | 16 +- internal/sync/engine.go | 48 ---- 8 files changed, 610 insertions(+), 128 deletions(-) create mode 100644 internal/parser/gptme_provider.go create mode 100644 internal/parser/gptme_provider_test.go diff --git a/internal/parser/gptme.go b/internal/parser/gptme.go index 166df2d94..2900fa4cb 100644 --- a/internal/parser/gptme.go +++ b/internal/parser/gptme.go @@ -5,62 +5,16 @@ import ( "fmt" "os" "path/filepath" - "sort" "strings" "time" "github.com/tidwall/gjson" ) -// DiscoverGptmeSessions finds gptme session files under the -// given logs directory. Each session is a subdirectory containing -// a conversation.jsonl file (e.g. ~/.local/share/gptme/logs/). -func DiscoverGptmeSessions(logsDir string) []DiscoveredFile { - if logsDir == "" { - return nil - } - entries, err := os.ReadDir(logsDir) - if err != nil { - return nil - } - var files []DiscoveredFile - for _, entry := range entries { - if !isDirOrSymlink(entry, logsDir) { - continue - } - convPath := filepath.Join(logsDir, entry.Name(), "conversation.jsonl") - if _, err := os.Stat(convPath); err != nil { - continue - } - files = append(files, DiscoveredFile{ - Path: convPath, - Agent: AgentGptme, - }) - } - sort.Slice(files, func(i, j int) bool { - return files[i].Path < files[j].Path - }) - return files -} - -// FindGptmeSourceFile locates a gptme session by its raw session ID -// (the directory name, without the "gptme:" prefix). -func FindGptmeSourceFile(logsDir, rawID string) string { - if logsDir == "" || rawID == "" { - return "" - } - candidate := filepath.Join(logsDir, rawID, "conversation.jsonl") - if info, err := os.Stat(candidate); err == nil && !info.IsDir() { - return candidate - } - return "" -} - -// ParseGptmeSession parses a gptme conversation.jsonl file. -// gptme stores one message per line with role/content/timestamp -// fields. Assistant messages carry an optional metadata object -// with model and usage sub-fields. -func ParseGptmeSession( +// parseSession parses a gptme conversation.jsonl file. gptme stores one +// message per line with role/content/timestamp fields. Assistant messages +// carry an optional metadata object with model and usage sub-fields. +func (p *gptmeProvider) parseSession( path, machine string, ) (*ParsedSession, []ParsedMessage, error) { info, err := os.Stat(path) diff --git a/internal/parser/gptme_provider.go b/internal/parser/gptme_provider.go new file mode 100644 index 000000000..a5711ca51 --- /dev/null +++ b/internal/parser/gptme_provider.go @@ -0,0 +1,323 @@ +package parser + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" +) + +type gptmeProviderFactory struct { + def AgentDef +} + +func newGptmeProviderFactory(def AgentDef) ProviderFactory { + return gptmeProviderFactory{def: cloneAgentDef(def)} +} + +func (f gptmeProviderFactory) Definition() AgentDef { + return cloneAgentDef(f.def) +} + +func (f gptmeProviderFactory) Capabilities() Capabilities { + return gptmeProviderCapabilities() +} + +func (f gptmeProviderFactory) NewProvider(cfg ProviderConfig) Provider { + cfg = cfg.Clone() + return &gptmeProvider{ + ProviderBase: ProviderBase{ + Def: cloneAgentDef(f.def), + Caps: gptmeProviderCapabilities(), + Config: cfg, + }, + sources: newGptmeSourceSet(cfg.Roots), + } +} + +type gptmeProvider struct { + ProviderBase + sources JSONLSourceSet +} + +func (p *gptmeProvider) Discover(ctx context.Context) ([]SourceRef, error) { + sources, err := p.sources.Discover(ctx) + if err != nil { + return nil, err + } + return p.filterSources(sources), nil +} + +func (p *gptmeProvider) WatchPlan(ctx context.Context) (WatchPlan, error) { + return p.sources.WatchPlan(ctx) +} + +func (p *gptmeProvider) SourcesForChangedPath( + ctx context.Context, + req ChangedPathRequest, +) ([]SourceRef, error) { + sources, err := p.sources.SourcesForChangedPath(ctx, req) + if err != nil { + return nil, err + } + filtered := p.filterSources(sources) + if len(filtered) > 0 { + return filtered, nil + } + source, ok := p.sourceForEventPath(req) + if !ok { + return nil, nil + } + return []SourceRef{source}, nil +} + +func (p *gptmeProvider) 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 + } + if source, ok, err := p.sourceForExistingPath(ctx, path); err != nil { + return SourceRef{}, false, err + } else if ok { + return source, true, nil + } + } + for _, id := range []string{ + req.RawSessionID, + p.rawSessionIDFromFull(req.FullSessionID), + } { + if id == "" { + continue + } + if source, ok, err := p.sourceForSessionID(ctx, id); err != nil { + return SourceRef{}, false, err + } else if ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func (p *gptmeProvider) sourceForExistingPath( + ctx context.Context, + path string, +) (SourceRef, bool, error) { + source, ok, err := p.sources.sourceForPath(ctx, path) + if err != nil { + return SourceRef{}, false, err + } + if ok && p.isSource(source) { + return source, true, nil + } + return SourceRef{}, false, nil +} + +func (p *gptmeProvider) sourceForSessionID( + ctx context.Context, + id string, +) (SourceRef, bool, error) { + for _, root := range p.Config.Roots { + path := filepath.Join(root, id, "conversation.jsonl") + if source, ok, err := p.sourceForExistingPath(ctx, path); err != nil { + return SourceRef{}, false, err + } else if ok { + return source, true, nil + } + } + return SourceRef{}, false, nil +} + +func (p *gptmeProvider) rawSessionIDFromFull(id string) string { + if id == "" { + return "" + } + _, rawID := StripHostPrefix(id) + if !strings.HasPrefix(rawID, p.Def.IDPrefix) { + return "" + } + return strings.TrimPrefix(rawID, p.Def.IDPrefix) +} + +func (p *gptmeProvider) sourceForEventPath(req ChangedPathRequest) (SourceRef, bool) { + if req.Path == "" { + return SourceRef{}, false + } + if req.WatchRoot != "" { + root := filepath.Clean(req.WatchRoot) + if !p.hasRoot(root) { + return SourceRef{}, false + } + return gptmeSourceRef(root, filepath.Clean(req.Path)) + } + for _, root := range p.Config.Roots { + if source, ok := gptmeSourceRef(root, filepath.Clean(req.Path)); ok { + return source, true + } + } + return SourceRef{}, false +} + +func (p *gptmeProvider) hasRoot(root string) bool { + for _, configured := range p.Config.Roots { + if samePath(configured, root) { + return true + } + } + return false +} + +func (p *gptmeProvider) Fingerprint( + ctx context.Context, + source SourceRef, +) (SourceFingerprint, error) { + return p.sources.Fingerprint(ctx, source) +} + +func (p *gptmeProvider) Parse( + ctx context.Context, + req ParseRequest, +) (ParseOutcome, error) { + if err := ctx.Err(); err != nil { + return ParseOutcome{}, err + } + path, ok, err := p.sources.pathFromSource(ctx, req.Source) + if err != nil { + return ParseOutcome{}, err + } + if !ok { + return ParseOutcome{}, fmt.Errorf("gptme source path unavailable") + } + machine := firstNonEmptyJSONLString(req.Machine, p.Config.Machine) + sess, msgs, err := p.parseSession(path, 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 +} + +func (p *gptmeProvider) filterSources(sources []SourceRef) []SourceRef { + if len(sources) == 0 { + return nil + } + filtered := sources[:0] + for _, source := range sources { + if p.isSource(source) { + filtered = append(filtered, source) + } + } + return filtered +} + +func (p *gptmeProvider) isSource(source SourceRef) bool { + src, ok := source.Opaque.(JSONLSource) + if !ok { + return false + } + return isGptmeConversationPath(src.Root, src.Path) +} + +func newGptmeSourceSet(roots []string) JSONLSourceSet { + return newJSONLSourceSet(AgentGptme, roots, + withRecursive(), + withContentHashing(), + withSymlinkFollowing(), + withInclude(func(path string, info os.FileInfo) bool { + return !info.IsDir() && filepath.Base(path) == "conversation.jsonl" + }), + withProjectHint(func(root, path string) string { + sessionID := gptmeSessionIDFromPath(root, path) + if sessionID == "" { + return "" + } + return gptmeProjectFromSessionName(sessionID) + }), + withSessionIDFromPath(gptmeSessionIDFromPath), + ) +} + +func gptmeProviderCapabilities() Capabilities { + return Capabilities{ + Source: SourceCapabilities{ + DiscoverSources: CapabilitySupported, + WatchSources: CapabilitySupported, + ClassifyChangedPath: CapabilitySupported, + FindSource: CapabilitySupported, + CompositeFingerprint: CapabilitySupported, + MultiSessionSource: CapabilityNotApplicable, + PerSessionErrors: CapabilityNotApplicable, + ExcludedSessions: CapabilityNotApplicable, + ForceReplaceOnParse: CapabilityNotApplicable, + }, + Content: ContentCapabilities{ + FirstMessage: CapabilitySupported, + Model: CapabilitySupported, + PerMessageTokenUsage: CapabilitySupported, + }, + } +} + +func isGptmeConversationPath(root, path string) bool { + rel, err := filepath.Rel(root, path) + if err != nil { + return false + } + parts := strings.Split(rel, string(filepath.Separator)) + return len(parts) == 2 && parts[1] == "conversation.jsonl" && + parts[0] != "." && parts[0] != ".." && parts[0] != "" +} + +func gptmeSessionIDFromPath(root, path string) string { + if !isGptmeConversationPath(root, path) { + return "" + } + return filepath.Base(filepath.Dir(path)) +} + +func gptmeSourceRef(root, path string) (SourceRef, bool) { + root = filepath.Clean(root) + path = filepath.Clean(path) + if !isGptmeConversationPath(root, path) { + return SourceRef{}, false + } + sessionID := gptmeSessionIDFromPath(root, path) + return SourceRef{ + Provider: AgentGptme, + Key: path, + DisplayPath: path, + FingerprintKey: path, + ProjectHint: gptmeProjectFromSessionName(sessionID), + Opaque: JSONLSource{ + Root: root, + Path: path, + RelPath: filepath.Join(sessionID, "conversation.jsonl"), + }, + }, true +} diff --git a/internal/parser/gptme_provider_test.go b/internal/parser/gptme_provider_test.go new file mode 100644 index 000000000..327be7e23 --- /dev/null +++ b/internal/parser/gptme_provider_test.go @@ -0,0 +1,230 @@ +package parser + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGptmeProviderFactoryReplacesLegacyAdapter(t *testing.T) { + factory, ok := ProviderFactoryByType(AgentGptme) + require.True(t, ok) + require.NotNil(t, factory) + + caps := factory.Capabilities() + assert.Equal(t, CapabilitySupported, caps.Source.DiscoverSources) + assert.Equal(t, CapabilitySupported, caps.Source.WatchSources) + assert.Equal(t, CapabilitySupported, caps.Source.ClassifyChangedPath) + assert.Equal(t, CapabilitySupported, caps.Source.FindSource) + assert.Equal(t, CapabilitySupported, caps.Source.CompositeFingerprint) + assert.Equal(t, CapabilityNotApplicable, caps.Source.MultiSessionSource) + assert.Equal(t, CapabilitySupported, caps.Content.FirstMessage) + assert.Equal(t, CapabilitySupported, caps.Content.Model) + assert.Equal(t, CapabilitySupported, caps.Content.PerMessageTokenUsage) + + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + require.NotNil(t, provider) +} + +func TestGptmeProviderSourceMethods(t *testing.T) { + root := t.TempDir() + sessionID := "2026-06-13-write-hello-world" + sourcePath := filepath.Join(root, sessionID, "conversation.jsonl") + writeSourceFile(t, sourcePath, gptmeProviderFixture()) + writeSourceFile(t, filepath.Join(root, "conversation.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "nested", sessionID, "conversation.jsonl"), "{}\n") + writeSourceFile(t, filepath.Join(root, "other", "notes.jsonl"), "{}\n") + + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, AgentGptme, discovered[0].Provider) + assert.Equal(t, sourcePath, discovered[0].Key) + assert.Equal(t, sourcePath, discovered[0].FingerprintKey) + assert.Equal(t, "write-hello-world", discovered[0].ProjectHint) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{Path: sourcePath, EventKind: "write", WatchRoot: root}, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, discovered[0].Key, changed[0].Key) + + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: sessionID, + }) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, discovered[0].Key, found.Key) + + fingerprint, err := provider.Fingerprint(context.Background(), found) + require.NoError(t, err) + assert.Equal(t, sourcePath, fingerprint.Key) + assert.NotZero(t, fingerprint.Size) + assert.NotZero(t, fingerprint.MTimeNS) + assert.NotEmpty(t, fingerprint.Hash) +} + +func TestGptmeProviderDiscoversSymlinkSessionDirectories(t *testing.T) { + root := t.TempDir() + targetRoot := t.TempDir() + sessionID := "2026-06-13-write-hello-world" + targetDir := filepath.Join(targetRoot, sessionID) + writeSourceFile( + t, + filepath.Join(targetDir, "conversation.jsonl"), + gptmeProviderFixture(), + ) + linkDir := filepath.Join(root, sessionID) + if err := os.Symlink(targetDir, linkDir); err != nil { + t.Skipf("creating directory symlink: %v", err) + } + + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + discovered, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, discovered, 1) + assert.Equal(t, filepath.Join(linkDir, "conversation.jsonl"), discovered[0].DisplayPath) +} + +func TestGptmeProviderClassifiesDeletedConversationPath(t *testing.T) { + root := t.TempDir() + sessionID := "2026-06-13-write-hello-world" + sourcePath := filepath.Join(root, sessionID, "conversation.jsonl") + writeSourceFile(t, sourcePath, gptmeProviderFixture()) + + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + require.NoError(t, os.Remove(sourcePath)) + + changed, err := provider.SourcesForChangedPath( + context.Background(), + ChangedPathRequest{ + Path: sourcePath, + EventKind: "remove", + WatchRoot: root, + }, + ) + require.NoError(t, err) + require.Len(t, changed, 1) + assert.Equal(t, sourcePath, changed[0].Key) + assert.Equal(t, sourcePath, changed[0].DisplayPath) + assert.Equal(t, "write-hello-world", changed[0].ProjectHint) +} + +func TestGptmeProviderFindSourceUsesPersistedFallbacks(t *testing.T) { + root := t.TempDir() + sessionID := "2026-06-13-write-hello-world" + sourcePath := filepath.Join(root, sessionID, "conversation.jsonl") + writeSourceFile(t, sourcePath, gptmeProviderFixture()) + + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + for _, req := range []FindSourceRequest{ + {FingerprintKey: sourcePath}, + {FullSessionID: "gptme:" + sessionID}, + {FullSessionID: "host~gptme:" + sessionID}, + } { + found, ok, err := provider.FindSource(context.Background(), req) + require.NoError(t, err) + require.Truef(t, ok, "request %#v", req) + assert.Equal(t, sourcePath, found.DisplayPath) + } +} + +func TestGptmeProviderParse(t *testing.T) { + root := t.TempDir() + sessionID := "2026-06-13-write-hello-world" + sourcePath := filepath.Join(root, sessionID, "conversation.jsonl") + writeSourceFile(t, sourcePath, gptmeProviderFixture()) + + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{root}, + Machine: "devbox", + }) + require.True(t, ok) + + source, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + StoredFilePath: sourcePath, + }) + require.NoError(t, err) + require.True(t, ok) + fingerprint, err := provider.Fingerprint(context.Background(), source) + require.NoError(t, err) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Fingerprint: fingerprint, + Machine: "devbox", + }) + require.NoError(t, err) + require.True(t, outcome.ResultSetComplete) + assert.False(t, outcome.ForceReplace) + assert.Empty(t, outcome.SourceErrors) + require.Len(t, outcome.Results, 1) + + result := outcome.Results[0] + assert.Equal(t, DataVersionCurrent, result.DataVersion) + assert.Empty(t, result.RetryReason) + assert.Equal(t, "gptme:"+sessionID, result.Result.Session.ID) + assert.Equal(t, "write-hello-world", result.Result.Session.Project) + assert.Equal(t, "devbox", result.Result.Session.Machine) + assert.Equal(t, fingerprint.Hash, result.Result.Session.File.Hash) + require.Len(t, result.Result.Messages, 2) + assert.Equal(t, RoleUser, result.Result.Messages[0].Role) + assert.Equal(t, RoleAssistant, result.Result.Messages[1].Role) +} + +func TestGptmeProviderParseMissingSourceIsWholeSourceError(t *testing.T) { + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{t.TempDir()}, + Machine: "devbox", + }) + require.True(t, ok) + + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: SourceRef{ + Provider: AgentGptme, + Key: "/tmp/missing/conversation.jsonl", + DisplayPath: "/tmp/missing/conversation.jsonl", + FingerprintKey: "/tmp/missing/conversation.jsonl", + }, + Machine: "devbox", + }) + require.Error(t, err) + assert.Empty(t, outcome) + assert.False(t, errors.Is(err, ErrUnsupportedProviderFeature)) +} + +func gptmeProviderFixture() string { + return `{"role":"user","content":"Write hello world.","timestamp":"2026-06-13T10:00:01.000000"}` + "\n" + + `{"role":"assistant","content":"Hello from gptme.","timestamp":"2026-06-13T10:00:02.000000","metadata":{"model":"demo-model","usage":{"input_tokens":10,"output_tokens":4}}}` + "\n" +} diff --git a/internal/parser/gptme_test.go b/internal/parser/gptme_test.go index 0394a20e2..f0525bc6e 100644 --- a/internal/parser/gptme_test.go +++ b/internal/parser/gptme_test.go @@ -1,6 +1,7 @@ package parser import ( + "context" "path/filepath" "testing" "time" @@ -9,18 +10,28 @@ import ( "github.com/stretchr/testify/require" ) -func TestParseGptmeSession(t *testing.T) { - path := filepath.Join( - "testdata", "gptme", - "2026-06-13-write-hello-world", - "conversation.jsonl", - ) +func TestGptmeProviderParsesFixture(t *testing.T) { + logsDir := filepath.Join("testdata", "gptme") - sess, msgs, err := ParseGptmeSession(path, "testmachine") + provider, ok := NewProvider(AgentGptme, ProviderConfig{ + Roots: []string{logsDir}, + Machine: "testmachine", + }) + require.True(t, ok) + source, found, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "2026-06-13-write-hello-world", + }) + require.NoError(t, err) + require.True(t, found) + outcome, err := provider.Parse(context.Background(), ParseRequest{ + Source: source, + Machine: "testmachine", + }) require.NoError(t, err) - require.NotNil(t, sess) - require.NotEmpty(t, msgs) + require.Len(t, outcome.Results, 1) + sess := outcome.Results[0].Result.Session + msgs := outcome.Results[0].Result.Messages assert.Equal(t, "gptme:2026-06-13-write-hello-world", sess.ID) assert.Equal(t, "write-hello-world", sess.Project) assert.Equal(t, "testmachine", sess.Machine) @@ -60,23 +71,35 @@ func TestParseGptmeSession(t *testing.T) { assert.Equal(t, 2, sess.UserMessageCount) } -func TestDiscoverGptmeSessions(t *testing.T) { +func TestGptmeProviderDiscoversFixture(t *testing.T) { logsDir := filepath.Join("testdata", "gptme") - files := DiscoverGptmeSessions(logsDir) - require.Len(t, files, 1) - assert.Equal(t, AgentGptme, files[0].Agent) - assert.Contains(t, files[0].Path, "conversation.jsonl") + provider, ok := NewProvider(AgentGptme, ProviderConfig{Roots: []string{logsDir}}) + require.True(t, ok) + + sources, err := provider.Discover(context.Background()) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, AgentGptme, sources[0].Provider) + assert.Contains(t, sources[0].DisplayPath, "conversation.jsonl") } -func TestFindGptmeSourceFile(t *testing.T) { +func TestGptmeProviderFindsFixtureSource(t *testing.T) { logsDir := filepath.Join("testdata", "gptme") + provider, ok := NewProvider(AgentGptme, ProviderConfig{Roots: []string{logsDir}}) + require.True(t, ok) - found := FindGptmeSourceFile(logsDir, "2026-06-13-write-hello-world") - assert.NotEmpty(t, found) - assert.Contains(t, found, "conversation.jsonl") + found, ok, err := provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "2026-06-13-write-hello-world", + }) + require.NoError(t, err) + require.True(t, ok) + assert.Contains(t, found.DisplayPath, "conversation.jsonl") - notFound := FindGptmeSourceFile(logsDir, "nonexistent-session") - assert.Empty(t, notFound) + _, ok, err = provider.FindSource(context.Background(), FindSourceRequest{ + RawSessionID: "nonexistent-session", + }) + require.NoError(t, err) + assert.False(t, ok) } func TestGptmeProjectFromSessionName(t *testing.T) { diff --git a/internal/parser/provider.go b/internal/parser/provider.go index 64745e779..7426f70ee 100644 --- a/internal/parser/provider.go +++ b/internal/parser/provider.go @@ -351,6 +351,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory { return newCommandCodeProviderFactory(def) case AgentIflow: return newIflowProviderFactory(def) + case AgentGptme: + return newGptmeProviderFactory(def) default: return legacyProviderFactory{def: def} } diff --git a/internal/parser/provider_migration.go b/internal/parser/provider_migration.go index 439ec5334..513a64d87 100644 --- a/internal/parser/provider_migration.go +++ b/internal/parser/provider_migration.go @@ -55,7 +55,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{ AgentVibe: ProviderMigrationLegacyOnly, AgentZed: ProviderMigrationLegacyOnly, AgentQwenPaw: ProviderMigrationLegacyOnly, - AgentGptme: ProviderMigrationLegacyOnly, + AgentGptme: ProviderMigrationProviderAuthoritative, AgentShelley: ProviderMigrationLegacyOnly, AgentAider: ProviderMigrationLegacyOnly, AgentOMP: ProviderMigrationLegacyOnly, diff --git a/internal/parser/types.go b/internal/parser/types.go index 59e3010ad..6d382f959 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -582,15 +582,13 @@ var Registry = []AgentDef{ FindSourceFunc: FindQwenPawSourceFile, }, { - Type: AgentGptme, - DisplayName: "gptme", - EnvVar: "GPTME_DIR", - ConfigKey: "gptme_dirs", - DefaultDirs: []string{".local/share/gptme/logs"}, - IDPrefix: "gptme:", - FileBased: true, - DiscoverFunc: DiscoverGptmeSessions, - FindSourceFunc: FindGptmeSourceFile, + Type: AgentGptme, + DisplayName: "gptme", + EnvVar: "GPTME_DIR", + ConfigKey: "gptme_dirs", + DefaultDirs: []string{".local/share/gptme/logs"}, + IDPrefix: "gptme:", + FileBased: true, }, { // Shelley (exe.dev) stores all conversations in a single diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 41d009f0f..732546e39 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -1450,23 +1450,6 @@ func (e *Engine) classifyOnePath( } } - // gptme: //conversation.jsonl - for _, gptmeDir := range e.agentDirs[parser.AgentGptme] { - if gptmeDir == "" { - continue - } - if rel, ok := isUnder(gptmeDir, path); ok { - parts := strings.Split(rel, sep) - if len(parts) != 2 || parts[1] != "conversation.jsonl" { - continue - } - return parser.DiscoveredFile{ - Path: path, - Agent: parser.AgentGptme, - }, true - } - } - if df, ok := e.classifyAiderPath(path); ok { return df, true } @@ -4643,8 +4626,6 @@ func (e *Engine) processFile( res = e.processAntigravityCLI(file, info) case parser.AgentQwenPaw: res = e.processQwenPaw(file, info) - case parser.AgentGptme: - res = e.processGptme(file, info) case parser.AgentAider: res = e.processAider(file, info) default: @@ -7000,35 +6981,6 @@ func (e *Engine) processPositron( } } -func (e *Engine) processGptme( - file parser.DiscoveredFile, info os.FileInfo, -) processResult { - if e.shouldSkipByPath(file.Path, info) { - return processResult{skip: true} - } - - sess, msgs, err := parser.ParseGptmeSession( - file.Path, e.machine, - ) - if err != nil { - return processResult{err: err} - } - if sess == nil { - return processResult{} - } - - hash, err := ComputeFileHash(file.Path) - if err == nil { - sess.File.Hash = hash - } - - return processResult{ - results: []parser.ParseResult{ - {Session: *sess, Messages: msgs}, - }, - } -} - // aiderFileUnchanged reports whether a physical aider history file is // unchanged since the last sync. Aider sessions are stored under virtual // "#" paths, so the generic shouldSkipByPath (which looks the