Files
wehub-resource-sync f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

1150 lines
31 KiB
Go

package parser
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
"github.com/tidwall/gjson"
)
// uuidRe matches a standard UUID (8-4-4-4-12 hex) at the end of a rollout filename stem.
var uuidRe = regexp.MustCompile(
`^rollout-.*-([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-` +
`[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`,
)
const (
copilotStateDir = "session-state"
geminiChatsDir = "chats"
)
// isDirOrSymlink reports whether the entry is a directory or a
// symlink that resolves to a directory. parentDir is needed to
// build the full path for symlink resolution.
func isDirOrSymlink(
entry os.DirEntry, parentDir string,
) bool {
if entry.IsDir() {
return true
}
if entry.Type()&os.ModeSymlink == 0 {
return false
}
fi, err := os.Stat(
filepath.Join(parentDir, entry.Name()),
)
if err != nil || fi == nil {
return false
}
return fi.IsDir()
}
// DiscoveredFile holds a discovered session file.
type DiscoveredFile struct {
Path string
Project string // pre-extracted project name
Agent AgentType // which agent this file belongs to
Machine string // source machine (set for s3:// sources; empty = host machine)
SourceSize int64 // source object size for s3:// sources
SourceMtime int64 // source object mtime for s3:// sources, UnixNano
// SourceFingerprint is a durable object fingerprint for s3:// sources.
SourceFingerprint string
ForceParse bool // caller requires a full source reparse
ProviderSource *SourceRef // provider-owned source identity, when known
ProviderProcess bool // true when this caller may parse via ProviderSource
}
// OpenCodeSourceMode identifies the usable OpenCode storage
// backend found under an OPENCODE_DIR root.
type OpenCodeSourceMode string
const (
OpenCodeSourceNone OpenCodeSourceMode = ""
OpenCodeSourceStorage OpenCodeSourceMode = "storage"
OpenCodeSourceSQLite OpenCodeSourceMode = "sqlite"
)
// OpenCodeSource describes the resolved storage backend for an
// OpenCode root.
type OpenCodeSource struct {
Mode OpenCodeSourceMode
Root string
SessionRoot string
DBPath string
}
// openCodeFormat parameterizes the shared OpenCode storage format by
// the per-agent SQLite filename, the storage/<sessionSubdir> that holds
// session JSON, and the agent label stamped on discovered sessions.
// Kilo is a fork of OpenCode with an identical on-disk layout; MiMoCode
// is a fork that stores sessions under storage/session_diff and a
// mimocode.db SQLite fallback. All share one implementation and differ
// only in these values.
type openCodeFormat struct {
agent AgentType
dbName string
sessionSubdir string
}
var (
openCodeFmt = openCodeFormat{
agent: AgentOpenCode, dbName: "opencode.db", sessionSubdir: "session",
}
kiloFmt = openCodeFormat{
agent: AgentKilo, dbName: "kilo.db", sessionSubdir: "session",
}
mimoFmt = openCodeFormat{
agent: AgentMiMoCode, dbName: "mimocode.db",
sessionSubdir: "session_diff",
}
icodemateFmt = openCodeFormat{
agent: AgentIcodemate, dbName: "icodemate.db",
sessionSubdir: "session_diff",
}
)
func resolveOpenCodeFormatSource(
f openCodeFormat, root string,
) OpenCodeSource {
if root == "" {
return OpenCodeSource{}
}
sessionRoot := filepath.Join(root, "storage", f.sessionSubdir)
if info, err := os.Stat(sessionRoot); err == nil && info.IsDir() {
return OpenCodeSource{
Mode: OpenCodeSourceStorage,
Root: root,
SessionRoot: sessionRoot,
DBPath: filepath.Join(root, f.dbName),
}
} else if err != nil && !os.IsNotExist(err) {
storageRoot := filepath.Join(root, "storage")
if info, serr := os.Stat(storageRoot); serr == nil && info.IsDir() {
return OpenCodeSource{
Mode: OpenCodeSourceStorage,
Root: root,
SessionRoot: sessionRoot,
DBPath: filepath.Join(root, f.dbName),
}
}
}
dbPath := filepath.Join(root, f.dbName)
if info, err := os.Stat(dbPath); err == nil && !info.IsDir() {
return OpenCodeSource{
Mode: OpenCodeSourceSQLite,
Root: root,
DBPath: dbPath,
}
}
return OpenCodeSource{Root: root}
}
func discoverOpenCodeFormatSessions(
f openCodeFormat, root string,
) []DiscoveredFile {
src := resolveOpenCodeFormatSource(f, root)
if src.Mode != OpenCodeSourceStorage {
return nil
}
var files []DiscoveredFile
entries, err := os.ReadDir(src.SessionRoot)
if err != nil {
return nil
}
for _, entry := range entries {
if !isDirOrSymlink(entry, src.SessionRoot) {
continue
}
projectDir := filepath.Join(src.SessionRoot, entry.Name())
sessionEntries, err := os.ReadDir(projectDir)
if err != nil {
continue
}
for _, sessionEntry := range sessionEntries {
if sessionEntry.IsDir() ||
!strings.HasSuffix(sessionEntry.Name(), ".json") {
continue
}
path := filepath.Join(projectDir, sessionEntry.Name())
files = append(files, DiscoveredFile{
Path: path,
Project: openCodeSessionProject(path),
Agent: f.agent,
})
}
}
sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files
}
func findOpenCodeFormatSourceFile(
f openCodeFormat, root, sessionID string,
) string {
if !IsValidSessionID(sessionID) {
return ""
}
src := resolveOpenCodeFormatSource(f, root)
switch src.Mode {
case OpenCodeSourceStorage:
if entries, err := os.ReadDir(src.SessionRoot); err == nil {
for _, entry := range entries {
if !isDirOrSymlink(entry, src.SessionRoot) {
continue
}
path := filepath.Join(
src.SessionRoot, entry.Name(),
sessionID+".json",
)
if info, err := os.Stat(path); err == nil &&
!info.IsDir() {
return path
}
}
}
if OpenCodeSQLiteSessionExists(src.DBPath, sessionID) {
return OpenCodeSQLiteVirtualPath(src.DBPath, sessionID)
}
return ""
case OpenCodeSourceSQLite:
if OpenCodeSQLiteSessionExists(src.DBPath, sessionID) {
return OpenCodeSQLiteVirtualPath(src.DBPath, sessionID)
}
return ""
default:
return ""
}
}
func openCodeFormatStorageSessionIDs(
f openCodeFormat, root string,
) map[string]struct{} {
src := resolveOpenCodeFormatSource(f, root)
if src.Mode != OpenCodeSourceStorage {
return nil
}
entries, err := os.ReadDir(src.SessionRoot)
if err != nil {
return nil
}
ids := make(map[string]struct{})
for _, entry := range entries {
if !isDirOrSymlink(entry, src.SessionRoot) {
continue
}
projectDir := filepath.Join(src.SessionRoot, entry.Name())
sessionEntries, err := os.ReadDir(projectDir)
if err != nil {
continue
}
for _, sessionEntry := range sessionEntries {
name := sessionEntry.Name()
if sessionEntry.IsDir() ||
!strings.HasSuffix(name, ".json") {
continue
}
id := strings.TrimSuffix(name, ".json")
if id == "" {
continue
}
ids[id] = struct{}{}
}
}
return ids
}
func resolveOpenCodeFormatWatchRoots(
f openCodeFormat, root string,
) []string {
if root == "" {
return nil
}
src := resolveOpenCodeFormatSource(f, root)
switch src.Mode {
case OpenCodeSourceStorage:
if info, err := os.Stat(src.DBPath); err == nil &&
!info.IsDir() {
return []string{root}
}
return []string{filepath.Join(root, "storage")}
case OpenCodeSourceSQLite:
return []string{root}
}
if info, err := os.Stat(root); err == nil && info.IsDir() {
return []string{root}
}
return nil
}
func parseOpenCodeFormatVirtualPath(
dbName, sourcePath string,
) (dbPath, sessionID string, ok bool) {
idx := strings.LastIndex(sourcePath, "#")
if idx <= 0 || idx >= len(sourcePath)-1 {
return "", "", false
}
dbPath = sourcePath[:idx]
sessionID = sourcePath[idx+1:]
if filepath.Base(dbPath) != dbName {
return "", "", false
}
return dbPath, sessionID, true
}
// ResolveOpenCodeSource detects whether an OpenCode root is using
// file-backed storage or legacy SQLite storage.
func ResolveOpenCodeSource(root string) OpenCodeSource {
return resolveOpenCodeFormatSource(openCodeFmt, root)
}
// ResolveOpenCodeWatchRoots returns the directories that should be
// watched for live OpenCode updates under a configured root. Pure
// storage mode targets the storage/ subtree so fsnotify does not
// recurse over unrelated opencode state (binaries, logs, caches),
// while still covering the session/message/part subdirs — including
// ones that OpenCode creates lazily after the watcher starts, since
// the watcher auto-adds new subdirectories on Create events. Hybrid
// storage+SQLite roots and pure SQLite mode watch the root so DB/WAL
// updates are observed too.
func ResolveOpenCodeWatchRoots(root string) []string {
return resolveOpenCodeFormatWatchRoots(openCodeFmt, root)
}
func OpenCodeSQLiteVirtualPath(
dbPath, sessionID string,
) string {
return dbPath + "#" + sessionID
}
func openCodeSessionProject(path string) string {
data, err := os.ReadFile(path)
if err == nil {
if cwd := gjson.GetBytes(data, "directory").Str; cwd != "" {
if project := ExtractProjectFromCwd(cwd); project != "" {
return project
}
}
}
if project := NormalizeName(filepath.Base(filepath.Dir(path))); project != "" {
return project
}
return "unknown"
}
// ResolveKiloSource detects whether a Kilo root is using file-backed
// storage or legacy SQLite storage.
func ResolveKiloSource(root string) OpenCodeSource {
return resolveOpenCodeFormatSource(kiloFmt, root)
}
func ResolveKiloWatchRoots(root string) []string {
return resolveOpenCodeFormatWatchRoots(kiloFmt, root)
}
func KiloSQLiteVirtualPath(dbPath, sessionID string) string {
return OpenCodeSQLiteVirtualPath(dbPath, sessionID)
}
// ResolveIcodemateSource detects whether an Icodemate root is using
// file-backed storage or legacy SQLite storage.
func ResolveIcodemateSource(root string) OpenCodeSource {
return resolveOpenCodeFormatSource(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 {
return resolveOpenCodeFormatSource(mimoFmt, root)
}
func ResolveMiMoCodeWatchRoots(root string) []string {
return resolveOpenCodeFormatWatchRoots(mimoFmt, root)
}
func MiMoCodeSQLiteVirtualPath(dbPath, sessionID string) string {
return OpenCodeSQLiteVirtualPath(dbPath, sessionID)
}
// ResolveCodexShallowWatchRoots returns directories that should be watched
// shallowly (root only) for live Codex updates, in addition to the recursive
// watch on the configured sessions root. Codex writes title renames to
// session_index.jsonl in the parent of sessions/ and archived_sessions/, so
// that parent must be watched for renames to surface without waiting for the
// periodic sync. A shallow watch avoids recursing over unrelated Codex state
// such as logs.
func ResolveCodexShallowWatchRoots(root string) []string {
parent := filepath.Dir(root)
if parent == "" || parent == "." || parent == root {
return nil
}
return []string{parent}
}
// ClaudeProjectSessionFiles finds all project directories under the
// Claude projects dir and returns their JSONL session files. It is the
// provider-owned enumeration body shared by the Claude provider source
// set (full-sync discovery) and the engine's duplicate-candidate
// expansion. The name carries no legacy entrypoint verb so the
// provider can call it without shimming a Discover* free function.
func ClaudeProjectSessionFiles(projectsDir string) []DiscoveredFile {
if strings.HasPrefix(projectsDir, "s3://") {
return discoverClaudeS3(projectsDir)
}
entries, err := os.ReadDir(projectsDir)
if err != nil {
return nil
}
var files []DiscoveredFile
for _, entry := range entries {
if !isDirOrSymlink(entry, projectsDir) {
continue
}
projDir := filepath.Join(projectsDir, entry.Name())
sessionFiles, err := os.ReadDir(projDir)
if err != nil {
continue
}
for _, sf := range sessionFiles {
if sf.IsDir() {
continue
}
name := sf.Name()
if !strings.HasSuffix(name, ".jsonl") {
continue
}
stem := strings.TrimSuffix(name, ".jsonl")
if strings.HasPrefix(stem, "agent-") {
continue
}
files = append(files, DiscoveredFile{
Path: filepath.Join(projDir, name),
Project: entry.Name(),
Agent: AgentClaude,
})
}
// Scan session directories for subagent files. Claude workflow
// tools group subagents under nested paths such as
// subagents/workflows/<workflow-id>/agent-<id>.jsonl, so walk the
// whole subagents tree instead of assuming transcripts are direct
// children of subagents/.
for _, sf := range sessionFiles {
if !sf.IsDir() {
continue
}
subagentsDir := filepath.Join(
projDir, sf.Name(), "subagents",
)
_ = filepath.WalkDir(
subagentsDir,
func(path string, sub os.DirEntry, err error) error {
if err != nil || sub.IsDir() {
return nil
}
name := sub.Name()
if !strings.HasPrefix(name, "agent-") ||
!strings.HasSuffix(name, ".jsonl") {
return nil
}
files = append(files, DiscoveredFile{
Path: path,
Project: entry.Name(),
Agent: AgentClaude,
})
return nil
},
)
}
}
sort.Slice(files, func(i, j int) bool {
return files[i].Path < files[j].Path
})
return files
}
// claudeFindSourceFile finds the original JSONL file for a Claude
// session ID by searching all project directories. It is the
// provider-owned lookup body used by the Claude provider source set's
// FindSource. The name carries no legacy entrypoint verb so the
// provider can call it without shimming a Find* free function.
func claudeFindSourceFile(
projectsDir, sessionID string,
) string {
if !IsValidSessionID(sessionID) {
return ""
}
entries, err := os.ReadDir(projectsDir)
if err != nil {
return ""
}
target := sessionID + ".jsonl"
for _, entry := range entries {
if !isDirOrSymlink(entry, projectsDir) {
continue
}
candidate := filepath.Join(
projectsDir, entry.Name(), target,
)
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
// Subagent files live under session directories:
// <project>/<session>/subagents/**/agent-<id>.jsonl
if strings.HasPrefix(sessionID, "agent-") {
for _, entry := range entries {
if !isDirOrSymlink(entry, projectsDir) {
continue
}
projDir := filepath.Join(
projectsDir, entry.Name(),
)
sessionDirs, err := os.ReadDir(projDir)
if err != nil {
continue
}
for _, sd := range sessionDirs {
if !sd.IsDir() {
continue
}
var found string
subagentsDir := filepath.Join(
projDir, sd.Name(), "subagents",
)
_ = filepath.WalkDir(
subagentsDir,
func(path string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || d.Name() != target {
return nil
}
found = path
return filepath.SkipAll
},
)
if found != "" {
return found
}
}
}
}
return ""
}
func isCodexSessionFilename(name string) bool {
return strings.HasPrefix(name, "rollout-") &&
strings.HasSuffix(name, ".jsonl")
}
// CodexSessionUUIDFromFilename extracts the canonical session UUID
// from a Codex rollout filename. Returns "" when the filename does
// not match Codex session naming.
func CodexSessionUUIDFromFilename(name string) string {
if !isCodexSessionFilename(name) {
return ""
}
return extractUUIDFromRollout(name)
}
// CodexLayout reports which on-disk layout a Codex session path uses.
type CodexLayout int
const (
CodexLayoutUnknown CodexLayout = iota
CodexLayoutArchivedFlat
CodexLayoutDated
)
// CodexSessionPathInfo parses a Codex path relative to a configured
// root and reports whether it is a valid session path plus its layout
// and canonical session UUID.
func CodexSessionPathInfo(root, path string) (CodexLayout, string, bool) {
root = filepath.Clean(root)
path = filepath.Clean(path)
rel, err := filepath.Rel(root, path)
if err != nil {
return CodexLayoutUnknown, "", false
}
sep := string(filepath.Separator)
if rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+sep) {
return CodexLayoutUnknown, "", false
}
if !strings.HasSuffix(path, ".jsonl") {
return CodexLayoutUnknown, "", false
}
parts := strings.Split(rel, sep)
switch len(parts) {
case 1:
if !isCodexSessionFilename(parts[0]) {
return CodexLayoutUnknown, "", false
}
return CodexLayoutArchivedFlat,
CodexSessionUUIDFromFilename(parts[0]), true
case 4:
if !IsDigits(parts[0]) || !IsDigits(parts[1]) || !IsDigits(parts[2]) {
return CodexLayoutUnknown, "", false
}
if !isCodexSessionFilename(parts[3]) {
return CodexLayoutUnknown, "", false
}
return CodexLayoutDated,
CodexSessionUUIDFromFilename(parts[3]), true
default:
return CodexLayoutUnknown, "", false
}
}
// walkCodexDayDirs traverses a Codex sessions directory with
// year/month/day structure, calling fn for each valid day directory.
// fn returns false to stop traversal.
func walkCodexDayDirs(
root string, fn func(dayPath string) bool,
) {
years, err := os.ReadDir(root)
if err != nil {
return
}
for _, year := range years {
if !year.IsDir() || !IsDigits(year.Name()) {
continue
}
yearPath := filepath.Join(root, year.Name())
months, err := os.ReadDir(yearPath)
if err != nil {
continue
}
for _, month := range months {
if !month.IsDir() || !IsDigits(month.Name()) {
continue
}
monthPath := filepath.Join(yearPath, month.Name())
days, err := os.ReadDir(monthPath)
if err != nil {
continue
}
for _, day := range days {
if !day.IsDir() || !IsDigits(day.Name()) {
continue
}
if !fn(filepath.Join(monthPath, day.Name())) {
return
}
}
}
}
}
// extractUUIDFromRollout extracts the UUID from a Codex filename
// like rollout-{timestamp}-{uuid}.jsonl using regex matching on the
// standard 8-4-4-4-12 hex format.
func extractUUIDFromRollout(filename string) string {
stem := strings.TrimSuffix(filename, ".jsonl")
match := uuidRe.FindStringSubmatch(stem)
if len(match) < 2 {
return ""
}
return match[1]
}
// IsDigits reports whether s is non-empty and contains only
// Unicode digit characters.
func IsDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
// IsValidSessionID reports whether id contains only
// alphanumeric characters, dashes, and underscores.
func IsValidSessionID(id string) bool {
if id == "" {
return false
}
for _, c := range id {
if !isAlphanumOrDashUnderscore(c) {
return false
}
}
return true
}
func isAlphanumOrDashUnderscore(c rune) bool {
return isAlphanum(c) ||
c == '-' || c == '_'
}
func isAlphanum(c rune) bool {
return (c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9')
}
func isValidAmpThreadID(id string) bool {
if !strings.HasPrefix(id, "T-") {
return false
}
if len(id) == len("T-") {
return false
}
if !isAlphanum(rune(id[len("T-")])) {
return false
}
return IsValidSessionID(id)
}
// IsAmpThreadFileName reports whether name matches the Amp
// thread file pattern (T-*.json).
func IsAmpThreadFileName(name string) bool {
if !strings.HasSuffix(name, ".json") {
return false
}
return isValidAmpThreadID(strings.TrimSuffix(name, ".json"))
}
func isGeminiSessionFilename(name string) bool {
return strings.HasPrefix(name, "session-") &&
(strings.HasSuffix(name, ".json") ||
strings.HasSuffix(name, ".jsonl"))
}
// geminiProjectsFile holds the structure of
// ~/.gemini/projects.json.
type geminiProjectsFile struct {
Projects map[string]string `json:"projects"`
}
// geminiTrustedFoldersFile holds the structure of
// ~/.gemini/trustedFolders.json.
type geminiTrustedFoldersFile struct {
TrustedFolders []string `json:"trustedFolders"`
}
// buildGeminiProjectMap reads ~/.gemini/projects.json and
// ~/.gemini/trustedFolders.json to build a map from directory
// name to resolved project name.
// BuildGeminiProjectMap reads Gemini config files and returns
// a map from directory name to resolved project name.
func BuildGeminiProjectMap(
geminiDir string,
) map[string]string {
result := make(map[string]string)
data, err := os.ReadFile(
filepath.Join(geminiDir, "projects.json"),
)
if err == nil {
var pf geminiProjectsFile
if err := json.Unmarshal(data, &pf); err == nil {
addProjectPaths(result, pf.Projects)
}
}
tfData, err := os.ReadFile(
filepath.Join(geminiDir, "trustedFolders.json"),
)
if err == nil {
var tf geminiTrustedFoldersFile
if err := json.Unmarshal(tfData, &tf); err == nil {
paths := make(
map[string]string, len(tf.TrustedFolders),
)
for _, p := range tf.TrustedFolders {
paths[p] = ""
}
addProjectPaths(result, paths)
}
}
return result
}
// addProjectPaths adds hash and name entries for the given
// absolute paths.
func addProjectPaths(
result map[string]string,
paths map[string]string,
) {
sorted := make([]string, 0, len(paths))
for absPath := range paths {
sorted = append(sorted, absPath)
}
sort.Strings(sorted)
for _, absPath := range sorted {
name := paths[absPath]
project := ExtractProjectFromCwd(absPath)
if project == "" {
project = "unknown"
}
hash := geminiPathHash(absPath)
if _, exists := result[hash]; !exists {
result[hash] = project
}
if name != "" {
if _, exists := result[name]; !exists {
result[name] = project
}
}
}
}
// geminiPathHash computes the SHA-256 hex hash of a path,
// matching Gemini CLI's project hash algorithm.
func geminiPathHash(path string) string {
h := sha256.Sum256([]byte(path))
return fmt.Sprintf("%x", h)
}
// isHexHash reports whether s is a 64-character lowercase hex
// string (i.e. a SHA-256 hash).
func isHexHash(s string) bool {
if len(s) != 64 {
return false
}
_, err := hex.DecodeString(s)
return err == nil
}
// resolveGeminiProject maps a tmp/ subdirectory name to a
// project name.
// ResolveGeminiProject maps a tmp/ subdirectory name to a
// project name using the project map.
func ResolveGeminiProject(
dirName string,
projectMap map[string]string,
) string {
if p := projectMap[dirName]; p != "" {
return p
}
if isHexHash(dirName) {
return "unknown"
}
return NormalizeName(dirName)
}
// IsPiSessionFile reads the first non-blank line of path and returns true
// when the JSON type field equals "session". OMP (Oh My Pi) v16.3+ prefixes
// session files with a fixed-width rewritable {"type":"title",...} slot
// line, so title lines before the session header are skipped, matching the
// header scan in parsePiLikeSession. The scanner buffer grows up to 64 MiB
// to match parser.maxLineSize. Leading blank lines are skipped to match
// lineReader behavior.
func IsPiSessionFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
s := bufio.NewScanner(f)
s.Buffer(make([]byte, 0, 64*1024), 64*1024*1024) // up to 64 MiB, matches parser.maxLineSize
for s.Scan() {
line := strings.TrimSpace(s.Text())
if line == "" {
continue
}
typ := gjson.Get(line, "type").Str
if typ == "title" {
continue
}
return typ == "session"
}
return false
}
// isRegularFile returns true if path exists and is a regular
// file (not a symlink, directory, or other special file).
// IsRegularFile reports whether path is a regular file (not
// a symlink, directory, or special file).
func IsRegularFile(path string) bool {
info, err := os.Lstat(path)
if err != nil {
return false
}
return info.Mode().IsRegular()
}
// isCursorTranscriptExt returns true if the filename has a
// recognized Cursor transcript extension (.txt or .jsonl).
// IsCursorTranscriptExt reports whether the filename has a
// recognized Cursor transcript extension (.txt or .jsonl).
func IsCursorTranscriptExt(name string) bool {
return strings.HasSuffix(name, ".txt") ||
strings.HasSuffix(name, ".jsonl")
}
// isContainedIn returns true if child is a path strictly
// under root. Both paths must be absolute / canonical.
func isContainedIn(child, root string) bool {
rel, err := filepath.Rel(root, child)
if err != nil {
return false
}
return rel != "." && rel != ".." &&
!strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// discoverVSCodeSessionFiles collects .json and .jsonl
// session files from a directory, preferring .jsonl when
// both exist for the same UUID.
func discoverVSCodeSessionFiles(
dir string, entries []os.DirEntry, project string, agent AgentType,
) []DiscoveredFile {
// Collect UUIDs that have .jsonl files
hasJSONL := make(map[string]bool)
for _, e := range entries {
if e.IsDir() {
continue
}
if uuid, ok := strings.CutSuffix(
e.Name(), ".jsonl",
); ok {
hasJSONL[uuid] = true
}
}
var files []DiscoveredFile
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".jsonl") {
files = append(files, DiscoveredFile{
Path: filepath.Join(dir, name),
Project: project,
Agent: agent,
})
} else if uuid, ok := strings.CutSuffix(name, ".json"); ok {
// Skip .json if a .jsonl exists for the same UUID
if hasJSONL[uuid] {
continue
}
files = append(files, DiscoveredFile{
Path: filepath.Join(dir, name),
Project: project,
Agent: agent,
})
}
}
return files
}
// discoverVisualStudioCopilotSessionFiles emits one work item per conversation
// found across the trace files in a directory. A single physical trace file
// can hold spans for several conversations, and one conversation can be split
// across rotating trace files, so each conversation is keyed independently and
// represented by the latest trace file that contains it. The work item path is
// a <traceFile>#<conversationID> virtual path so the parser can re-gather that
// conversation's spans from all sibling files.
func discoverVisualStudioCopilotSessionFiles(
dir string, entries []os.DirEntry,
) []DiscoveredFile {
bestByConversation := map[string]visualStudioCopilotCandidate{}
var unreadable []DiscoveredFile
for _, entry := range entries {
if entry.IsDir() ||
!isVisualStudioCopilotTraceFileName(entry.Name()) {
continue
}
path := filepath.Join(dir, entry.Name())
mtime := time.Time{}
if info, err := entry.Info(); err == nil {
mtime = info.ModTime()
}
ids, err := VisualStudioCopilotFileConversationIDs(path)
if err != nil {
// Enqueue the physical file so the sync worker surfaces the
// read failure instead of silently dropping every
// conversation it might contain.
unreadable = append(unreadable, DiscoveredFile{
Path: path,
Project: "visualstudio",
Agent: AgentVSCopilot,
})
continue
}
for _, id := range ids {
current := bestByConversation[id]
if visualStudioCopilotCandidateWins(path, mtime, current) {
bestByConversation[id] = visualStudioCopilotCandidate{
path: path, mtime: mtime,
}
}
}
}
files := make([]DiscoveredFile, 0, len(bestByConversation)+len(unreadable))
for id, c := range bestByConversation {
files = append(files, DiscoveredFile{
Path: VisualStudioCopilotVirtualPath(c.path, id),
Project: "visualstudio",
Agent: AgentVSCopilot,
})
}
files = append(files, unreadable...)
return files
}
func findVisualStudioCopilotTraceSourceFile(
dir, rawID string,
) string {
entries, err := os.ReadDir(dir)
if err != nil {
return ""
}
needle := `"gen_ai.conversation.id"`
valueNeedle := `"stringValue":"` + rawID + `"`
var best visualStudioCopilotCandidate
for _, entry := range entries {
if entry.IsDir() ||
!isVisualStudioCopilotTraceFileName(entry.Name()) {
continue
}
path := filepath.Join(dir, entry.Name())
if !visualStudioCopilotTraceContains(path, needle, valueNeedle) {
continue
}
mtime := time.Time{}
if info, err := entry.Info(); err == nil {
mtime = info.ModTime()
}
// Select the same canonical trace discovery would (newest mtime, then
// greater path) so a single-session resync resolves the conversation to
// the identical virtual path discovery stored, even when filename order
// and mtime order disagree.
if visualStudioCopilotCandidateWins(path, mtime, best) {
best = visualStudioCopilotCandidate{path: path, mtime: mtime}
}
}
if best.path == "" {
return ""
}
// Return a conversation-scoped virtual path. The stored file_path is a
// <traceFile>#<conversationID> key, and returning the bare trace file would
// let a single-session resync enumerate and rewrite every conversation in
// that trace rather than only the requested one.
return VisualStudioCopilotVirtualPath(best.path, rawID)
}
// visualStudioCopilotCandidate is one trace file considered as the canonical
// home for a conversation: its path and mtime.
type visualStudioCopilotCandidate struct {
path string
mtime time.Time
}
// isVisualStudioCopilotTraceFileName reports whether name is a Visual Studio
// Copilot trace file.
func isVisualStudioCopilotTraceFileName(name string) bool {
return strings.HasSuffix(name, ".jsonl") &&
strings.Contains(name, "_VSGitHubCopilot_traces")
}
// visualStudioCopilotCandidateWins reports whether the trace at (path, mtime)
// should replace the current best candidate. The canonical rule, shared by
// discovery and single-session lookup, is newest mtime first, then the
// lexicographically greater path as a deterministic tie-breaker. A zero-value
// best (empty path) means no candidate has been chosen yet.
func visualStudioCopilotCandidateWins(
path string, mtime time.Time, best visualStudioCopilotCandidate,
) bool {
if best.path == "" {
return true
}
return mtime.After(best.mtime) ||
(mtime.Equal(best.mtime) && path > best.path)
}
func visualStudioCopilotTraceContains(
path, keyNeedle, valueNeedle string,
) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 256*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, keyNeedle) &&
strings.Contains(line, valueNeedle) {
return true
}
}
return false
}
// extractIflowBaseSessionID extracts the base session ID from an iFlow
// session ID. Fork IDs are formatted as <baseUUID>-<childUUID>, so we
// remove the child UUID suffix to get the base session ID for file lookup.
// Both base and child UUIDs are full UUIDs with hyphens, so we count
// hyphens to determine where the base UUID ends (after 4 hyphens).
func extractIflowBaseSessionID(sessionID string) string {
// iFlow fork IDs have the format: baseUUID-childUUID
// where both are full UUIDs with hyphens.
// A standard UUID has format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (4 hyphens)
// So the base UUID ends after the 4th hyphen, and the child UUID starts after that.
hyphenCount := 0
for i, r := range sessionID {
if r == '-' {
hyphenCount++
// The 5th hyphen marks the boundary between base and child UUIDs
if hyphenCount == 5 {
// Return everything before this hyphen (the base UUID)
return sessionID[:i]
}
}
}
// If we didn't find 5 hyphens, this is not a fork ID
return sessionID
}