Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions internal/parser/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ func providerFactoryForDef(def AgentDef) ProviderFactory {
return newPiProviderFactory(def)
case AgentQwen:
return newQwenProviderFactory(def)
case AgentWorkBuddy:
return newWorkBuddyProviderFactory(def)
case AgentZencoder:
return newZencoderProviderFactory(def)
default:
Expand Down
2 changes: 1 addition & 1 deletion internal/parser/provider_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ var providerMigrationModes = map[AgentType]ProviderMigrationMode{
AgentKiroIDE: ProviderMigrationLegacyOnly,
AgentCortex: ProviderMigrationLegacyOnly,
AgentHermes: ProviderMigrationLegacyOnly,
AgentWorkBuddy: ProviderMigrationLegacyOnly,
AgentWorkBuddy: ProviderMigrationProviderAuthoritative,
AgentForge: ProviderMigrationLegacyOnly,
AgentPiebald: ProviderMigrationLegacyOnly,
AgentWarp: ProviderMigrationLegacyOnly,
Expand Down
16 changes: 7 additions & 9 deletions internal/parser/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,15 +456,13 @@ var Registry = []AgentDef{
FindSourceFunc: FindHermesSourceFile,
},
{
Type: AgentWorkBuddy,
DisplayName: "WorkBuddy",
EnvVar: "WORKBUDDY_PROJECTS_DIR",
ConfigKey: "workbuddy_project_dirs",
DefaultDirs: []string{".workbuddy/projects"},
IDPrefix: "workbuddy:",
FileBased: true,
DiscoverFunc: DiscoverWorkBuddySessions,
FindSourceFunc: FindWorkBuddySourceFile,
Type: AgentWorkBuddy,
DisplayName: "WorkBuddy",
EnvVar: "WORKBUDDY_PROJECTS_DIR",
ConfigKey: "workbuddy_project_dirs",
DefaultDirs: []string{".workbuddy/projects"},
IDPrefix: "workbuddy:",
FileBased: true,
},
{
Type: AgentForge,
Expand Down
97 changes: 1 addition & 96 deletions internal/parser/workbuddy.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,108 +5,13 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/tidwall/gjson"
)

func DiscoverWorkBuddySessions(projectsDir string) []DiscoveredFile {
if projectsDir == "" {
return nil
}

projects, err := os.ReadDir(projectsDir)
if err != nil {
return nil
}

var files []DiscoveredFile
for _, projEntry := range projects {
if !isDirOrSymlink(projEntry, projectsDir) {
continue
}
project := projEntry.Name()
projectDir := filepath.Join(projectsDir, project)
entries, err := os.ReadDir(projectDir)
if err != nil {
continue
}
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() && strings.HasSuffix(name, ".jsonl") {
stem := strings.TrimSuffix(name, ".jsonl")
if IsValidSessionID(stem) {
files = append(files, DiscoveredFile{
Path: filepath.Join(projectDir, name),
Project: project,
Agent: AgentWorkBuddy,
})
}
continue
}
if !isDirOrSymlink(entry, projectDir) || !IsValidSessionID(name) {
continue
}
subagentsDir := filepath.Join(projectDir, name, "subagents")
subagents, err := os.ReadDir(subagentsDir)
if err != nil {
continue
}
for _, sub := range subagents {
if sub.IsDir() || !strings.HasSuffix(sub.Name(), ".jsonl") {
continue
}
files = append(files, DiscoveredFile{
Path: filepath.Join(subagentsDir, sub.Name()),
Project: project,
Agent: AgentWorkBuddy,
})
}
}
}

sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files
}

func FindWorkBuddySourceFile(projectsDir, rawID string) string {
if projectsDir == "" {
return ""
}
rawID = strings.TrimPrefix(rawID, "workbuddy:")
sessionID, subagentID, hasSubagent := strings.Cut(rawID, ":subagent:")
if !IsValidSessionID(sessionID) {
return ""
}
if hasSubagent && !IsValidSessionID(subagentID) {
return ""
}

projects, err := os.ReadDir(projectsDir)
if err != nil {
return ""
}
for _, projEntry := range projects {
if !isDirOrSymlink(projEntry, projectsDir) {
continue
}
projectDir := filepath.Join(projectsDir, projEntry.Name())
candidate := filepath.Join(projectDir, sessionID+".jsonl")
if hasSubagent {
candidate = filepath.Join(projectDir, sessionID, "subagents", subagentID+".jsonl")
}
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return ""
}

func ParseWorkBuddySession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) {
func parseWorkBuddySession(path, project, machine string) (*ParsedSession, []ParsedMessage, error) {
info, err := os.Stat(path)
if err != nil {
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
Expand Down
129 changes: 129 additions & 0 deletions internal/parser/workbuddy_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package parser

import (
"context"
"path/filepath"
"strings"
)

// WorkBuddy stores each session as a JSONL file in a project directory, with
// subagent transcripts nested under a "subagents" subdirectory. It is a
// directory-of-files provider: discovery, watching, change classification,
// lookup, and fingerprinting come from JSONLSourceSet, and the ParseFile option
// makes that source set a full SourceSet so it rides the generic factory.
func newWorkBuddyProviderFactory(def AgentDef) ProviderFactory {
return newSourceSetFactory(
def,
workBuddyProviderCapabilities(),
func(cfg ProviderConfig) SourceSet { return newWorkBuddySourceSet(cfg.Roots) },
)
}

func newWorkBuddySourceSet(roots []string) JSONLSourceSet {
return newJSONLSourceSet(AgentWorkBuddy, roots,
withRecursive(),
withSymlinkFollowing(),
withContentHashing(),
withIncludePath(isWorkBuddySourcePath),
withProjectHint(workBuddyProjectHintFromPath),
withSessionIDFromPath(workBuddySessionIDFromPath),
withLookupIDValid(isWorkBuddyLookupID),
withParseFile(workBuddyParseFile),
)
}

func workBuddyParseFile(
_ context.Context, path string, req ParseRequest,
) ([]ParseResult, []string, error) {
sess, msgs, err := parseWorkBuddySession(path, req.Source.ProjectHint, req.Machine)
if err != nil {
return nil, nil, err
}
if sess == nil {
return nil, nil, nil
}
if req.Fingerprint.Hash != "" {
sess.File.Hash = req.Fingerprint.Hash
}
return []ParseResult{{Session: *sess, Messages: msgs}}, nil, nil
}

func isWorkBuddySourcePath(root, path string) bool {
parts, ok := workBuddyPathParts(root, path)
if !ok {
return false
}
switch len(parts) {
case 2:
stem, ok := strings.CutSuffix(parts[1], ".jsonl")
return ok && IsValidSessionID(stem)
case 4:
return IsValidSessionID(parts[1]) &&
parts[2] == "subagents" &&
strings.HasSuffix(parts[3], ".jsonl")
default:
return false
}
}

func workBuddyProjectHintFromPath(root, path string) string {
parts, ok := workBuddyPathParts(root, path)
if !ok || len(parts) < 2 {
return ""
}
return parts[0]
}

func workBuddySessionIDFromPath(root, path string) string {
if !isWorkBuddySourcePath(root, path) {
return ""
}
parts, _ := workBuddyPathParts(root, path)
stem := strings.TrimSuffix(filepath.Base(path), ".jsonl")
if len(parts) == 4 {
return parts[1] + ":subagent:" + stem
}
return stem
}

func isWorkBuddyLookupID(rawID string) bool {
if rawID == "" {
return false
}
sessionID, subagentID, hasSubagent := strings.Cut(rawID, ":subagent:")
if !IsValidSessionID(sessionID) {
return false
}
return !hasSubagent || IsValidSessionID(subagentID)
}

func workBuddyPathParts(root, path string) ([]string, bool) {
rel, err := filepath.Rel(root, path)
if err != nil {
return nil, false
}
parts := strings.Split(rel, string(filepath.Separator))
for _, part := range parts {
if part == "" || part == "." || part == ".." {
return nil, false
}
}
return parts, true
}

func workBuddyProviderCapabilities() Capabilities {
return Capabilities{
Source: jsonlFileProviderSourceCapabilities(),
Content: ContentCapabilities{
FirstMessage: CapabilitySupported,
Cwd: CapabilitySupported,
Relationships: CapabilitySupported,
Subagents: CapabilitySupported,
ToolCalls: CapabilitySupported,
ToolResults: CapabilitySupported,
PerMessageTokenUsage: CapabilitySupported,
MalformedLineCount: CapabilitySupported,
Model: CapabilitySupported,
},
}
}
Loading