diff --git a/internal/parser/discovery.go b/internal/parser/discovery.go index 9afa5f705..67e90c36c 100644 --- a/internal/parser/discovery.go +++ b/internal/parser/discovery.go @@ -99,6 +99,10 @@ var ( agent: AgentMiMoCode, dbName: "mimocode.db", sessionSubdir: "session_diff", } + icodemateFmt = openCodeFormat{ + agent: AgentIcodemate, dbName: "icodemate.db", + sessionSubdir: "session_diff", + } ) func resolveOpenCodeFormatSource( @@ -400,6 +404,36 @@ func ParseKiloSQLiteVirtualPath( return parseOpenCodeFormatVirtualPath(kiloFmt.dbName, sourcePath) } +func ResolveIcodemateSource(root string) OpenCodeSource { + return resolveOpenCodeFormatSource(icodemateFmt, root) +} + +func DiscoverIcodemateSessions(root string) []DiscoveredFile { + return discoverOpenCodeFormatSessions(icodemateFmt, root) +} + +func FindIcodemateSourceFile(root, sessionID string) string { + return findOpenCodeFormatSourceFile(icodemateFmt, root, sessionID) +} + +func IcodemateStorageSessionIDs(root string) map[string]struct{} { + return openCodeFormatStorageSessionIDs(icodemateFmt, root) +} + +func ResolveIcodemateWatchRoots(root string) []string { + return resolveOpenCodeFormatWatchRoots(icodemateFmt, root) +} + +func IcodemateSQLiteVirtualPath(dbPath, sessionID string) string { + return OpenCodeSQLiteVirtualPath(dbPath, sessionID) +} + +func ParseIcodemateSQLiteVirtualPath( + sourcePath string, +) (dbPath, sessionID string, ok bool) { + return parseOpenCodeFormatVirtualPath(icodemateFmt.dbName, sourcePath) +} + // ResolveMiMoCodeSource detects whether a MiMoCode root is using // file-backed storage (storage/session_diff) or SQLite storage. func ResolveMiMoCodeSource(root string) OpenCodeSource { diff --git a/internal/parser/icodemate.go b/internal/parser/icodemate.go new file mode 100644 index 000000000..3d5604414 --- /dev/null +++ b/internal/parser/icodemate.go @@ -0,0 +1,65 @@ +package parser + +import "strings" + +// Icodemate uses OpenCode's storage format, and is exposed as a distinct +// agent with the icodemate: ID prefix. +func ParseIcodemateFile( + sessionPath, machine string, +) (*ParsedSession, []ParsedMessage, error) { + sess, msgs, err := ParseOpenCodeFile(sessionPath, machine) + if err != nil || sess == nil { + return sess, msgs, err + } + relabelOpenCodeSessionAsIcodemate(sess) + return sess, msgs, nil +} + +func ParseIcodemateSession( + dbPath, sessionID, machine string, +) (*ParsedSession, []ParsedMessage, error) { + sess, msgs, err := ParseOpenCodeSession(dbPath, sessionID, machine) + if err != nil || sess == nil { + return sess, msgs, err + } + relabelOpenCodeSessionAsIcodemate(sess) + return sess, msgs, nil +} + +func ListIcodemateSessionMeta(dbPath string) ([]OpenCodeSessionMeta, error) { + metas, err := ListOpenCodeSessionMeta(dbPath) + if err != nil { + return nil, err + } + for i := range metas { + metas[i].VirtualPath = IcodemateSQLiteVirtualPath( + dbPath, metas[i].SessionID, + ) + } + return metas, nil +} + +func IcodemateSourceMtime(sourcePath string) (int64, error) { + if sourcePath == "" { + return 0, nil + } + if dbPath, sessionID, ok := ParseIcodemateSQLiteVirtualPath(sourcePath); ok { + return openCodeSQLiteSessionMtime(dbPath, sessionID) + } + return openCodeStorageSessionMtime(sourcePath) +} + +func relabelOpenCodeSessionAsIcodemate(sess *ParsedSession) { + sess.ID = strings.Replace(sess.ID, "opencode:", "icodemate:", 1) + if sess.ParentSessionID != "" { + sess.ParentSessionID = strings.Replace( + sess.ParentSessionID, "opencode:", "icodemate:", 1, + ) + } + if sess.SourceSessionID != "" { + sess.SourceSessionID = strings.Replace( + sess.SourceSessionID, "opencode:", "icodemate:", 1, + ) + } + sess.Agent = AgentIcodemate +} diff --git a/internal/parser/icodemate_test.go b/internal/parser/icodemate_test.go new file mode 100644 index 000000000..f4cc6e1fe --- /dev/null +++ b/internal/parser/icodemate_test.go @@ -0,0 +1,111 @@ +package parser + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseIcodemateFileRelabelsOpenCodeSession(t *testing.T) { + root := t.TempDir() + sessionPath := filepath.Join( + root, "storage", "session_diff", "global", "ses_icode.json", + ) + writeOpenCodeStorageFile(t, sessionPath, map[string]any{ + "id": "ses_icode", + "parentID": "ses_parent", + "directory": "/home/user/code/icodeapp", + "title": "IcodeMate Session", + "time": map[string]any{ + "created": 1700000000000, + "updated": 1700000060000, + }, + }) + writeOpenCodeStorageFile(t, filepath.Join( + root, "storage", "message", "ses_icode", "msg_1.json", + ), map[string]any{ + "id": "msg_1", + "sessionID": "ses_icode", + "role": "user", + "time": map[string]any{ + "created": 1700000000000, + }, + }) + writeOpenCodeStorageFile(t, filepath.Join( + root, "storage", "part", "msg_1", "prt_1.json", + ), map[string]any{ + "id": "prt_1", + "sessionID": "ses_icode", + "messageID": "msg_1", + "type": "text", + "text": "Hello from IcodeMate", + "time": map[string]any{ + "created": 1700000000000, + }, + }) + + sess, msgs, err := ParseIcodemateFile(sessionPath, "testmachine") + require.NoError(t, err) + require.NotNil(t, sess) + require.Len(t, msgs, 1) + + assert.Equal(t, "icodemate:ses_icode", sess.ID) + assert.Equal(t, "icodemate:ses_parent", sess.ParentSessionID) + assert.Equal(t, AgentIcodemate, sess.Agent) + assert.Equal(t, "icodeapp", sess.Project) + assert.Equal(t, "Hello from IcodeMate", msgs[0].Content) +} + +func TestDiscoverIcodemateSessions(t *testing.T) { + root := t.TempDir() + sessionPath := filepath.Join( + root, "storage", "session_diff", "global", "ses_icode.json", + ) + writeOpenCodeStorageFile(t, sessionPath, map[string]any{ + "id": "ses_icode", + "directory": "/home/user/code/icodeapp", + "time": map[string]any{ + "created": 1700000000000, + "updated": 1700000060000, + }, + }) + + files := DiscoverIcodemateSessions(root) + require.Len(t, files, 1) + + assert.Equal(t, sessionPath, files[0].Path) + assert.Equal(t, "icodeapp", files[0].Project) + assert.Equal(t, AgentIcodemate, files[0].Agent) +} + +func TestParseIcodemateSQLiteVirtualPath(t *testing.T) { + wantDBPath := filepath.Join(t.TempDir(), "icodemate.db") + virtual := wantDBPath + "#ses_icode" + dbPath, sessionID, ok := ParseIcodemateSQLiteVirtualPath(virtual) + require.True(t, ok) + assert.Equal(t, wantDBPath, dbPath) + assert.Equal(t, "ses_icode", sessionID) + + _, _, ok = ParseIcodemateSQLiteVirtualPath( + filepath.Join(t.TempDir(), "opencode.db") + "#ses_icode", + ) + assert.False(t, ok) +} + +func TestDiscoverIcodemateSessionsEmptyDir(t *testing.T) { + root := t.TempDir() + files := DiscoverIcodemateSessions(root) + assert.Empty(t, files) +} + +func TestDiscoverIcodemateSessionsNoSessionDiff(t *testing.T) { + root := t.TempDir() + writeOpenCodeStorageFile(t, + filepath.Join(root, "storage", "other", "x.json"), + map[string]any{"id": "x"}, + ) + files := DiscoverIcodemateSessions(root) + assert.Empty(t, files) +} diff --git a/internal/parser/types.go b/internal/parser/types.go index fd08c94f2..1d0c0142d 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -53,6 +53,7 @@ const ( AgentShelley AgentType = "shelley" AgentAider AgentType = "aider" AgentReasonix AgentType = "reasonix" + AgentIcodemate AgentType = "icodemate" ) // AgentDef describes a supported coding agent's filesystem @@ -655,6 +656,19 @@ var Registry = []AgentDef{ DiscoverFunc: DiscoverReasonixSessions, FindSourceFunc: FindReasonixSourceFile, }, + { + Type: AgentIcodemate, + DisplayName: "IcodeMate", + EnvVar: "ICODEMATE_DIR", + ConfigKey: "icodemate_dirs", + DefaultDirs: []string{".local/share/icodemate"}, + IDPrefix: "icodemate:", + WatchSubdirs: []string{"storage/session_diff"}, + FileBased: true, + DiscoverFunc: DiscoverIcodemateSessions, + FindSourceFunc: FindIcodemateSourceFile, + WatchRootsFunc: ResolveIcodemateWatchRoots, + }, } // NonFileBackedAgents returns agent types where FileBased is false. diff --git a/internal/parser/types_test.go b/internal/parser/types_test.go index d62e602c3..5ef958ea0 100644 --- a/internal/parser/types_test.go +++ b/internal/parser/types_test.go @@ -343,6 +343,7 @@ func TestRegistryCompleteness(t *testing.T) { AgentAntigravity, AgentAntigravityCLI, AgentIflow, + AgentIcodemate, AgentWorkBuddy, AgentZencoder, AgentGptme, diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 2a103c910..986c22486 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -678,6 +678,11 @@ func (e *Engine) classifyContainerPath( ); ok { return df, true } + if df, ok := e.classifyOpenCodeFormatPath( + parser.AgentIcodemate, path, pathExists, + ); ok { + return df, true + } if df, ok := e.classifyKiroSQLitePath(path); ok { return df, true } @@ -2067,6 +2072,9 @@ func (e *Engine) resyncAllLocked( oldFileSessions -= e.countRootOpenCodeFormatSessions( origDB, parser.AgentMiMoCode, ) + oldFileSessions -= e.countRootOpenCodeFormatSessions( + origDB, parser.AgentIcodemate, + ) if oldFileSessions < 0 { oldFileSessions = 0 } @@ -2974,6 +2982,15 @@ func (e *Engine) syncAllLocked( return stats } } + if scope.includesAny(e.agentDirs[parser.AgentIcodemate]) { + if e.syncOpenCodeFormatAgent( + ctx, parser.AgentIcodemate, "icodemate", + writeMode, verbose, scope, &stats, advanceDBProgress, + ) { + stats.Aborted = true + return stats + } + } // Sync Warp sessions (DB-backed, not file-based). tWarp := time.Now() @@ -3701,7 +3718,7 @@ func (e *Engine) countDBBackedProgressTotal( switch agent { case parser.AgentKiro: total += e.countOneKiroSQLiteSessions(dir) - case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode: + case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode, parser.AgentIcodemate: total += e.countOneOpenCodeFormatSessions(agent, dir) case parser.AgentWarp: total += e.countOneWarpSessions(dir) @@ -3726,6 +3743,7 @@ func (e *Engine) countDBBackedSessions( parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode, + parser.AgentIcodemate, parser.AgentWarp, parser.AgentForge, parser.AgentPiebald, @@ -4418,7 +4436,7 @@ func (e *Engine) processFile( res = e.processReasonix(file, info) case parser.AgentGemini: res = e.processGemini(file, info) - case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode: + case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode, parser.AgentIcodemate: res = e.processOpenCodeFormat(file.Agent, file, info) case parser.AgentOpenHands: res = e.processOpenHands(file, info) @@ -8475,6 +8493,7 @@ func (e *Engine) shouldPreserveOpenCodeFormatArchive( func isOpenCodeFormatStorageAgent(agent parser.AgentType) bool { return agent == parser.AgentOpenCode || agent == parser.AgentKilo || + agent == parser.AgentIcodemate || agent == parser.AgentMiMoCode } @@ -8486,6 +8505,8 @@ func openCodeFormatDBName(agent parser.AgentType) string { return "kilo.db" case parser.AgentMiMoCode: return "mimocode.db" + case parser.AgentIcodemate: + return "icodemate.db" default: return "" } @@ -8501,6 +8522,8 @@ func resolveOpenCodeFormatSource( return parser.ResolveKiloSource(dir) case parser.AgentMiMoCode: return parser.ResolveMiMoCodeSource(dir) + case parser.AgentIcodemate: + return parser.ResolveIcodemateSource(dir) default: return parser.OpenCodeSource{} } @@ -8516,6 +8539,8 @@ func openCodeFormatSourceMtime( return parser.KiloSourceMtime(path) case parser.AgentMiMoCode: return parser.MiMoCodeSourceMtime(path) + case parser.AgentIcodemate: + return parser.IcodemateSourceMtime(path) default: return 0, fmt.Errorf("unknown OpenCode-format agent: %s", agent) } @@ -8556,6 +8581,8 @@ func parseOpenCodeFormatSQLiteVirtualPath( return parser.ParseKiloSQLiteVirtualPath(path) case parser.AgentMiMoCode: return parser.ParseMiMoCodeSQLiteVirtualPath(path) + case parser.AgentIcodemate: + return parser.ParseIcodemateSQLiteVirtualPath(path) default: return parser.ParseOpenCodeSQLiteVirtualPath(path) } @@ -8569,6 +8596,8 @@ func listOpenCodeFormatSessionMeta( return parser.ListKiloSessionMeta(dbPath) case parser.AgentMiMoCode: return parser.ListMiMoCodeSessionMeta(dbPath) + case parser.AgentIcodemate: + return parser.ListIcodemateSessionMeta(dbPath) default: return parser.ListOpenCodeSessionMeta(dbPath) } @@ -8582,6 +8611,8 @@ func openCodeFormatStorageSessionIDs( return parser.KiloStorageSessionIDs(dir) case parser.AgentMiMoCode: return parser.MiMoCodeStorageSessionIDs(dir) + case parser.AgentIcodemate: + return parser.IcodemateStorageSessionIDs(dir) default: return parser.OpenCodeStorageSessionIDs(dir) } @@ -8595,6 +8626,8 @@ func findOpenCodeFormatSourceFile( return parser.FindKiloSourceFile(dir, sessionID) case parser.AgentMiMoCode: return parser.FindMiMoCodeSourceFile(dir, sessionID) + case parser.AgentIcodemate: + return parser.FindIcodemateSourceFile(dir, sessionID) default: return parser.FindOpenCodeSourceFile(dir, sessionID) } @@ -8608,6 +8641,8 @@ func parseOpenCodeFormatSession( return parser.ParseKiloSession(dbPath, sessionID, machine) case parser.AgentMiMoCode: return parser.ParseMiMoCodeSession(dbPath, sessionID, machine) + case parser.AgentIcodemate: + return parser.ParseIcodemateSession(dbPath, sessionID, machine) default: return parser.ParseOpenCodeSession(dbPath, sessionID, machine) } @@ -8621,6 +8656,8 @@ func parseOpenCodeFormatFile( return parser.ParseKiloFile(path, machine) case parser.AgentMiMoCode: return parser.ParseMiMoCodeFile(path, machine) + case parser.AgentIcodemate: + return parser.ParseIcodemateFile(path, machine) default: return parser.ParseOpenCodeFile(path, machine) } diff --git a/internal/sync/parsediff.go b/internal/sync/parsediff.go index 1542cb095..fc3ef7b04 100644 --- a/internal/sync/parsediff.go +++ b/internal/sync/parsediff.go @@ -285,7 +285,7 @@ func (e *Engine) parseDiffDatabaseSources( }) } } - case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode: + case parser.AgentOpenCode, parser.AgentKilo, parser.AgentMiMoCode, parser.AgentIcodemate: for _, dir := range e.agentDirs[def.Type] { if dir == "" { continue @@ -368,6 +368,9 @@ func stripVirtualSourceSuffix(path string) string { if dbPath, _, ok := parser.ParseMiMoCodeSQLiteVirtualPath(path); ok { return dbPath } + if dbPath, _, ok := parser.ParseIcodemateSQLiteVirtualPath(path); ok { + return dbPath + } if dbPath, _, ok := parser.ParseShelleyVirtualPath(path); ok { return dbPath }