Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
717 lines
24 KiB
Go
717 lines
24 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
|
|
agentactivitybiz "github.com/tutti-os/tutti/packages/agent/store-sqlite"
|
|
agentproviderbiz "github.com/tutti-os/tutti/services/tuttid/biz/agentprovider"
|
|
)
|
|
|
|
func (*Service) ScanExternalImports(ctx context.Context, input ExternalImportScanInput) (ExternalImportScanResult, error) {
|
|
data, err := scanExternalAgentSessions(ctx, normalizeExternalImportProviders(input.Providers), input.Days, input.ArchivePath, input.ArchiveKind)
|
|
if err != nil {
|
|
return ExternalImportScanResult{}, err
|
|
}
|
|
return data.result, nil
|
|
}
|
|
|
|
func (s *Service) ImportExternalSessions(ctx context.Context, workspaceID string, input ExternalImportInput) (ExternalImportResult, error) {
|
|
workspaceID = strings.TrimSpace(workspaceID)
|
|
if workspaceID == "" || len(input.Projects) == 0 {
|
|
return ExternalImportResult{}, ErrInvalidArgument
|
|
}
|
|
if s == nil || s.ExternalImportStore == nil {
|
|
return ExternalImportResult{}, errors.New("external agent import store is unavailable")
|
|
}
|
|
selections := normalizeExternalImportSelections(input.Projects)
|
|
if len(selections) == 0 {
|
|
return ExternalImportResult{}, ErrInvalidArgument
|
|
}
|
|
// Scan all available history when importing: the request already filters by
|
|
// explicit project paths and session ids, and the picker may surface
|
|
// conversations older than the default 30-day window.
|
|
data, err := scanExternalAgentSessions(
|
|
ctx,
|
|
providersFromExternalImportSelections(selections),
|
|
-1,
|
|
input.ArchivePath,
|
|
input.ArchiveKind,
|
|
)
|
|
if err != nil {
|
|
return ExternalImportResult{}, err
|
|
}
|
|
result := ExternalImportResult{
|
|
SkippedSessions: data.result.SkippedSessions,
|
|
Errors: append([]ExternalImportError(nil), data.result.Errors...),
|
|
}
|
|
importedProjectPaths := map[string]struct{}{}
|
|
validProjectPaths := map[string]int64{}
|
|
for _, session := range data.sessions {
|
|
if ctx.Err() != nil {
|
|
return result, ctx.Err()
|
|
}
|
|
projectPath, selected := matchingExternalImportProject(session, selections)
|
|
if !selected {
|
|
continue
|
|
}
|
|
importedMessages, imported, err := s.importExternalSession(ctx, workspaceID, session, projectPath)
|
|
if err != nil {
|
|
result.Errors = append(result.Errors, ExternalImportError{
|
|
Provider: session.Provider,
|
|
SourcePath: session.SourcePath,
|
|
Message: err.Error(),
|
|
})
|
|
continue
|
|
}
|
|
// Only mark projectPath as a candidate for ProjectPaths once its session
|
|
// has actually landed in the store without error. Marking it here
|
|
// unconditionally (before knowing whether importExternalSession
|
|
// succeeded) let a project whose only session failed to import (a
|
|
// transient store error, a write timeout, etc.) still come back in
|
|
// ProjectPaths — and registerExternalImportUserProjects registers
|
|
// whatever ProjectPaths contains, so that project would surface as a
|
|
// folder card with "No chats yet" and no way to tell it apart from a
|
|
// project that imported cleanly.
|
|
if !session.NoProject && session.UpdatedAtUnixMS > validProjectPaths[projectPath] {
|
|
validProjectPaths[projectPath] = session.UpdatedAtUnixMS
|
|
}
|
|
if imported {
|
|
result.ImportedSessions++
|
|
}
|
|
if importedMessages > 0 {
|
|
result.ImportedMessages += importedMessages
|
|
if !session.NoProject {
|
|
importedProjectPaths[projectPath] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
result.ImportedProjects = len(importedProjectPaths)
|
|
result.ProjectPaths = sortedProjectPathsByLatest(validProjectPaths)
|
|
return result, nil
|
|
}
|
|
|
|
func normalizeExternalImportProviders(input []string) []string {
|
|
if len(input) == 0 {
|
|
out := make([]string, 0)
|
|
for _, descriptor := range providerregistry.Migrated() {
|
|
if descriptor.ExternalImport.Enabled {
|
|
out = append(out, descriptor.Identity.ID)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(input))
|
|
for _, provider := range input {
|
|
normalized := agentproviderbiz.Normalize(provider)
|
|
if normalized == "" {
|
|
// Import-only archive providers (e.g. the ChatGPT data export) are
|
|
// deliberately not runnable registry providers, so they never
|
|
// resolve through providerregistry. Preserve their identity here so
|
|
// import selections that reference them survive normalization.
|
|
normalized = normalizeExternalArchiveImportProvider(provider)
|
|
if normalized == "" {
|
|
continue
|
|
}
|
|
} else {
|
|
descriptor, ok := providerregistry.Find(normalized)
|
|
if !ok || !descriptor.ExternalImport.Enabled {
|
|
continue
|
|
}
|
|
}
|
|
if _, ok := seen[normalized]; ok {
|
|
continue
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
out = append(out, normalized)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeExternalArchiveImportProvider recognizes import-only archive
|
|
// providers that have no runnable providerregistry descriptor. Returns the
|
|
// canonical identity, or "" if the value is not a known archive-only provider.
|
|
func normalizeExternalArchiveImportProvider(provider string) string {
|
|
switch strings.TrimSpace(strings.ToLower(provider)) {
|
|
case chatgptExportProvider:
|
|
return chatgptExportProvider
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeExternalImportSelections(input []ExternalImportProjectSelection) []ExternalImportProjectSelection {
|
|
out := make([]ExternalImportProjectSelection, 0, len(input))
|
|
for _, selection := range input {
|
|
path, ok := canonicalExistingDir(selection.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
out = append(out, ExternalImportProjectSelection{
|
|
Path: path,
|
|
Providers: normalizeExternalImportProviders(selection.Providers),
|
|
SessionIDs: normalizeExternalImportSessionIDs(selection.SessionIDs),
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeExternalImportSessionIDs(input []string) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, len(input))
|
|
for _, id := range input {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
out = append(out, id)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providersFromExternalImportSelections(selections []ExternalImportProjectSelection) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0, 2)
|
|
for _, selection := range selections {
|
|
for _, provider := range normalizeExternalImportProviders(selection.Providers) {
|
|
if _, ok := seen[provider]; ok {
|
|
continue
|
|
}
|
|
seen[provider] = struct{}{}
|
|
out = append(out, provider)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// externalImportedSessionSettings carries the provider-reported model/effort
|
|
// forward into the imported session's composer settings so continuing the
|
|
// conversation in Tutti reuses the same model configuration the user's local
|
|
// CLI had, instead of silently falling back to workspace defaults.
|
|
func externalImportedSessionSettings(session externalImportedSession) map[string]any {
|
|
reasoningEffort := strings.TrimSpace(session.ReasoningEffort)
|
|
if !composerProviderUsesModelReasoningCatalog(session.Provider) {
|
|
reasoningEffort = normalizeReasoningEffortForProvider(session.Provider, reasoningEffort)
|
|
}
|
|
settings := ComposerSettings{
|
|
Model: strings.TrimSpace(session.Model),
|
|
ReasoningEffort: reasoningEffort,
|
|
}
|
|
if composerSettingsIsEmpty(settings) {
|
|
return nil
|
|
}
|
|
return composerSettingsToPayload(settings)
|
|
}
|
|
|
|
func externalImportAgentTargetID(provider string) string {
|
|
normalized := agentproviderbiz.Normalize(provider)
|
|
if descriptor, ok := providerregistry.Find(normalized); ok {
|
|
return descriptor.Target.ID
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func scanExternalAgentSessions(ctx context.Context, providers []string, days int, archivePath string, archiveKind string) (externalScanData, error) {
|
|
if strings.TrimSpace(archivePath) != "" {
|
|
archiveKind = normalizeExternalImportArchiveKind(archiveKind)
|
|
// The Claude archive scan still requires the claude-code provider to be
|
|
// selected (its historical gate); ChatGPT archives are a standalone
|
|
// import-only source and carry no such provider requirement.
|
|
if archiveKind == ExternalImportArchiveKindClaude &&
|
|
len(providers) > 0 && !providersIncludeArchiveImportParser(providers) {
|
|
return externalScanData{}, fmt.Errorf(
|
|
"%w: a Claude export archive scan requires the claude-code provider",
|
|
ErrInvalidArgument,
|
|
)
|
|
}
|
|
// An export archive is a complete snapshot, so no implicit 30-day
|
|
// window applies; only an explicit positive day range narrows it.
|
|
cutoffUnixMS := int64(0)
|
|
if days > 0 {
|
|
cutoffUnixMS = externalScanCutoffUnixMS(days)
|
|
}
|
|
switch archiveKind {
|
|
case ExternalImportArchiveKindChatGPT:
|
|
return scanChatGPTExportArchive(ctx, archivePath, cutoffUnixMS)
|
|
default:
|
|
return scanClaudeExportArchive(ctx, archivePath, cutoffUnixMS)
|
|
}
|
|
}
|
|
data := externalScanData{}
|
|
projects := map[string]*ExternalImportProject{}
|
|
cutoffUnixMS := externalScanCutoffUnixMS(days)
|
|
for _, provider := range normalizeExternalImportProviders(providers) {
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
sessions, summary, errors := scanExternalProviderSessions(provider, cutoffUnixMS)
|
|
data.result.Providers = append(data.result.Providers, summary)
|
|
data.result.Errors = append(data.result.Errors, errors...)
|
|
for _, session := range sessions {
|
|
project, ok := projectFromExternalSession(session)
|
|
if !ok {
|
|
data.result.SkippedSessions++
|
|
continue
|
|
}
|
|
data.sessions = append(data.sessions, session)
|
|
data.result.ScannedSessions++
|
|
data.result.ScannedMessages += len(session.Messages)
|
|
data.result.Sessions = append(data.result.Sessions, externalImportSessionSummary(session, project.Path))
|
|
upsertExternalImportProject(projects, project, session.Provider)
|
|
}
|
|
}
|
|
for _, project := range projects {
|
|
sort.Strings(project.Providers)
|
|
data.result.Projects = append(data.result.Projects, *project)
|
|
}
|
|
sort.SliceStable(data.result.Projects, func(left, right int) bool {
|
|
if data.result.Projects[left].LastUpdatedAtUnixMS == data.result.Projects[right].LastUpdatedAtUnixMS {
|
|
return data.result.Projects[left].Path < data.result.Projects[right].Path
|
|
}
|
|
return data.result.Projects[left].LastUpdatedAtUnixMS > data.result.Projects[right].LastUpdatedAtUnixMS
|
|
})
|
|
sort.SliceStable(data.result.Sessions, func(left, right int) bool {
|
|
if data.result.Sessions[left].LastUpdatedAtUnixMS == data.result.Sessions[right].LastUpdatedAtUnixMS {
|
|
return data.result.Sessions[left].ID < data.result.Sessions[right].ID
|
|
}
|
|
return data.result.Sessions[left].LastUpdatedAtUnixMS > data.result.Sessions[right].LastUpdatedAtUnixMS
|
|
})
|
|
// The provider loop breaks on cancellation, so surface it instead of
|
|
// letting a partial scan pass as a complete result.
|
|
if err := ctx.Err(); err != nil {
|
|
return externalScanData{}, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// normalizeExternalImportArchiveKind resolves a request archive kind, defaulting
|
|
// an empty value to Claude for backward compatibility with clients that only
|
|
// send archivePath.
|
|
func normalizeExternalImportArchiveKind(kind string) string {
|
|
switch strings.TrimSpace(strings.ToLower(kind)) {
|
|
case ExternalImportArchiveKindChatGPT:
|
|
return ExternalImportArchiveKindChatGPT
|
|
case "", ExternalImportArchiveKindClaude:
|
|
return ExternalImportArchiveKindClaude
|
|
default:
|
|
// Unknown kinds fall back to Claude rather than silently importing
|
|
// nothing; the API layer validates the enum before reaching here.
|
|
return ExternalImportArchiveKindClaude
|
|
}
|
|
}
|
|
|
|
func providersIncludeArchiveImportParser(providers []string) bool {
|
|
for _, provider := range providers {
|
|
descriptor, ok := providerregistry.Find(provider)
|
|
if ok && descriptor.ExternalImport.ParserKind == providerregistry.ExternalImportParserKindClaudeJSONL {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// externalScanCutoffUnixMS resolves the "updated since" cutoff for a scan
|
|
// window expressed in days. 0 keeps the historical 30-day default; a negative
|
|
// value disables the cutoff so all available history is scanned (used by the
|
|
// import path, which filters by explicit selections instead).
|
|
func externalScanCutoffUnixMS(days int) int64 {
|
|
if days < 0 {
|
|
return 0
|
|
}
|
|
if days == 0 {
|
|
days = 30
|
|
}
|
|
return time.Now().Add(-time.Duration(days) * 24 * time.Hour).UnixMilli()
|
|
}
|
|
|
|
func scanExternalProviderSessions(provider string, cutoffUnixMS int64) ([]externalImportedSession, ExternalImportProvider, []ExternalImportError) {
|
|
descriptor, ok := providerregistry.Find(provider)
|
|
if !ok || !descriptor.ExternalImport.Enabled {
|
|
return nil, ExternalImportProvider{Provider: provider}, nil
|
|
}
|
|
root := externalProviderRoot(descriptor.ExternalImport)
|
|
summary := ExternalImportProvider{Provider: provider, Root: root}
|
|
if root == "" {
|
|
return nil, summary, nil
|
|
}
|
|
if info, err := os.Stat(root); err != nil || !info.IsDir() {
|
|
return nil, summary, nil
|
|
}
|
|
summary.Available = true
|
|
files, err := externalProviderJSONLFiles(descriptor.ExternalImport, root)
|
|
if err != nil {
|
|
summary.Error = err.Error()
|
|
return nil, summary, []ExternalImportError{{Provider: provider, Message: err.Error()}}
|
|
}
|
|
// Codex stores the generated conversation title in its app-server SQLite
|
|
// state DB rather than in the rollout transcript, so resolve it up front and
|
|
// let it override the message-derived title.
|
|
var importedTitles map[string]string
|
|
if descriptor.ExternalImport.TitleCatalogKind == providerregistry.ExternalImportTitleCatalogKindCodexSQLite {
|
|
importedTitles = codexThreadTitles(root)
|
|
}
|
|
sessions := make([]externalImportedSession, 0, len(files))
|
|
errors := make([]ExternalImportError, 0)
|
|
for _, file := range files {
|
|
session, ok, err := parseExternalProviderJSONL(descriptor, file)
|
|
if err != nil {
|
|
errors = append(errors, ExternalImportError{Provider: provider, SourcePath: file, Message: err.Error()})
|
|
continue
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
if session.UpdatedAtUnixMS < cutoffUnixMS {
|
|
continue
|
|
}
|
|
if title := strings.TrimSpace(importedTitles[session.ProviderSessionID]); title != "" {
|
|
session.Title = truncateExternalTitle(title)
|
|
}
|
|
sessions = append(sessions, session)
|
|
summary.SessionCount++
|
|
summary.MessageCount += len(session.Messages)
|
|
}
|
|
return sessions, summary, errors
|
|
}
|
|
|
|
func externalProviderRoot(descriptor providerregistry.ExternalImportDescriptor) string {
|
|
home, _ := os.UserHomeDir()
|
|
if descriptor.RootEnvVar != "" {
|
|
if root := strings.TrimSpace(os.Getenv(descriptor.RootEnvVar)); root != "" {
|
|
return root
|
|
}
|
|
}
|
|
if home != "" && strings.HasPrefix(descriptor.DefaultRoot, "~/") {
|
|
return filepath.Join(home, strings.TrimPrefix(descriptor.DefaultRoot, "~/"))
|
|
}
|
|
return strings.TrimSpace(descriptor.DefaultRoot)
|
|
}
|
|
|
|
func externalProviderJSONLFiles(descriptor providerregistry.ExternalImportDescriptor, root string) ([]string, error) {
|
|
roots := make([]string, 0, len(descriptor.ScanDirectories))
|
|
for _, directory := range descriptor.ScanDirectories {
|
|
roots = append(roots, filepath.Join(root, directory))
|
|
}
|
|
files := make([]string, 0)
|
|
for _, scanRoot := range roots {
|
|
if info, err := os.Stat(scanRoot); err != nil || !info.IsDir() {
|
|
continue
|
|
}
|
|
err := filepath.WalkDir(scanRoot, func(path string, entry os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if entry.IsDir() {
|
|
for _, prefix := range descriptor.SkipDirectoryPrefixes {
|
|
if strings.HasPrefix(entry.Name(), prefix) {
|
|
return filepath.SkipDir
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if strings.EqualFold(filepath.Ext(path), ".jsonl") {
|
|
files = append(files, path)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
return files, nil
|
|
}
|
|
|
|
func parseExternalProviderJSONL(descriptor providerregistry.ProviderDescriptor, path string) (externalImportedSession, bool, error) {
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return externalImportedSession{}, false, err
|
|
}
|
|
defer file.Close()
|
|
var session externalImportedSession
|
|
var ok bool
|
|
switch descriptor.ExternalImport.ParserKind {
|
|
case providerregistry.ExternalImportParserKindCodexJSONL:
|
|
session, ok, err = parseCodexJSONL(path, file)
|
|
case providerregistry.ExternalImportParserKindClaudeJSONL:
|
|
session, ok, err = parseClaudeCodeJSONL(path, file)
|
|
default:
|
|
return externalImportedSession{}, false, fmt.Errorf("external import parser %q is unsupported", descriptor.ExternalImport.ParserKind)
|
|
}
|
|
if ok {
|
|
session.Provider = descriptor.Identity.ID
|
|
}
|
|
return session, ok, err
|
|
}
|
|
|
|
func (s *Service) importExternalSession(
|
|
ctx context.Context,
|
|
workspaceID string,
|
|
session externalImportedSession,
|
|
projectPath string,
|
|
) (int, bool, error) {
|
|
agentSessionID := externalImportedSessionID(session.Provider, session.ProviderSessionID)
|
|
existingTurnIDs, sessionExists, err := s.existingExternalImportMessageTurnIDs(ctx, workspaceID, agentSessionID)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
updates := make([]agentactivitybiz.MessageUpdate, 0, len(session.Messages))
|
|
currentTurnID := ""
|
|
for i, message := range session.Messages {
|
|
messageID := externalImportedMessageIDForMessage(session.Provider, session.ProviderSessionID, message, i)
|
|
if message.Role == "user" && message.Kind == "text" {
|
|
currentTurnID = externalImportedTurnID(messageID)
|
|
}
|
|
existingTurnID, exists := existingTurnIDs[messageID]
|
|
if exists && (currentTurnID == "" || existingTurnID == currentTurnID) {
|
|
continue
|
|
}
|
|
updates = append(updates, agentactivitybiz.MessageUpdate{
|
|
MessageID: messageID,
|
|
TurnID: currentTurnID,
|
|
Role: message.Role,
|
|
Kind: message.Kind,
|
|
Status: message.Status,
|
|
Payload: externalImportedMessagePayload(message),
|
|
OccurredAtUnixMS: message.OccurredAtUnixMS,
|
|
StartedAtUnixMS: message.StartedAtUnixMS,
|
|
CompletedAtUnixMS: message.CompletedAtUnixMS,
|
|
})
|
|
}
|
|
runtimeContext := map[string]any{
|
|
"visible": true,
|
|
"imported": true,
|
|
"externalImportNoProject": session.NoProject,
|
|
"externalSourcePath": session.SourcePath,
|
|
}
|
|
if session.ResumeSupported != nil {
|
|
runtimeContext["externalImportResumeSupported"] = *session.ResumeSupported
|
|
}
|
|
if _, err := s.ExternalImportStore.ReportSessionState(ctx, agentactivitybiz.SessionStateReport{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: agentSessionID,
|
|
Origin: WorkspaceAgentSessionOriginImported,
|
|
AgentTargetID: externalImportAgentTargetID(session.Provider),
|
|
Provider: session.Provider,
|
|
ProviderSessionID: session.ProviderSessionID,
|
|
Model: session.Model,
|
|
Settings: externalImportedSessionSettings(session),
|
|
RuntimeContext: runtimeContext,
|
|
Cwd: session.Cwd,
|
|
ImportProjectPath: projectPath,
|
|
Title: session.Title,
|
|
Status: "completed",
|
|
CurrentPhase: "completed",
|
|
OccurredAtUnixMS: session.UpdatedAtUnixMS,
|
|
StartedAtUnixMS: session.StartedAtUnixMS,
|
|
EndedAtUnixMS: session.UpdatedAtUnixMS,
|
|
}); err != nil {
|
|
return 0, false, err
|
|
}
|
|
if len(updates) == 0 && sessionExists {
|
|
return 0, false, nil
|
|
}
|
|
importedMessages := 0
|
|
for start := 0; start < len(updates); start += 200 {
|
|
end := start + 200
|
|
if end > len(updates) {
|
|
end = len(updates)
|
|
}
|
|
report, err := s.ExternalImportStore.ReportSessionMessages(ctx, agentactivitybiz.SessionMessageReport{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: agentSessionID,
|
|
Origin: WorkspaceAgentSessionOriginImported,
|
|
Provider: session.Provider,
|
|
HistoricalImport: true,
|
|
Messages: updates[start:end],
|
|
})
|
|
if err != nil {
|
|
return importedMessages, true, err
|
|
}
|
|
importedMessages += report.AcceptedCount
|
|
}
|
|
return importedMessages, true, nil
|
|
}
|
|
|
|
func (s *Service) existingExternalImportMessageTurnIDs(ctx context.Context, workspaceID string, agentSessionID string) (map[string]string, bool, error) {
|
|
turnIDs := map[string]string{}
|
|
if s == nil || s.ExternalImportStore == nil {
|
|
return turnIDs, false, nil
|
|
}
|
|
if _, ok, err := s.ExternalImportStore.GetSession(ctx, workspaceID, agentSessionID); err != nil || !ok {
|
|
return turnIDs, ok, err
|
|
}
|
|
var after uint64
|
|
for {
|
|
page, ok, err := s.ExternalImportStore.ListSessionMessages(ctx, agentactivitybiz.ListSessionMessagesInput{
|
|
WorkspaceID: workspaceID,
|
|
AgentSessionID: agentSessionID,
|
|
AfterVersion: after,
|
|
Limit: 1000,
|
|
Order: agentactivitybiz.MessageOrderAsc,
|
|
})
|
|
if err != nil || !ok {
|
|
return turnIDs, true, err
|
|
}
|
|
if len(page.Messages) == 0 {
|
|
return turnIDs, true, nil
|
|
}
|
|
for _, message := range page.Messages {
|
|
turnIDs[strings.TrimSpace(message.MessageID)] = strings.TrimSpace(message.TurnID)
|
|
if message.Version > after {
|
|
after = message.Version
|
|
}
|
|
}
|
|
if !page.HasMore {
|
|
return turnIDs, true, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func externalImportedSessionID(provider string, providerSessionID string) string {
|
|
return "imported-" + externalImportProviderSlug(provider) + "-" + externalStableHash(providerSessionID)[:24]
|
|
}
|
|
|
|
// externalImportProviderSlug resolves a stable, filesystem-safe provider slug
|
|
// for imported session identities. Registered runnable providers normalize to
|
|
// their canonical id; import-only sources (e.g. the ChatGPT data export, which
|
|
// is deliberately not a runnable registry provider) fall back to a sanitized
|
|
// form of their raw identity so their session ids stay readable and unique
|
|
// instead of collapsing to an empty segment.
|
|
func externalImportProviderSlug(provider string) string {
|
|
if normalized := agentproviderbiz.Normalize(provider); normalized != "" {
|
|
return normalized
|
|
}
|
|
var builder strings.Builder
|
|
for _, r := range strings.ToLower(strings.TrimSpace(provider)) {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
|
|
builder.WriteRune(r)
|
|
default:
|
|
builder.WriteRune('-')
|
|
}
|
|
}
|
|
slug := strings.Trim(builder.String(), "-")
|
|
if slug == "" {
|
|
return "import"
|
|
}
|
|
return slug
|
|
}
|
|
|
|
func externalImportedMessageID(provider string, providerSessionID string, rawID string, index int) string {
|
|
return "imported-" + externalStableHash(provider + "\x00" + providerSessionID + "\x00" + rawID + "\x00" + strconv.Itoa(index))[:32]
|
|
}
|
|
|
|
func externalImportedMessageIDForMessage(provider string, providerSessionID string, message externalImportedMessage, index int) string {
|
|
if seed := strings.TrimSpace(message.MessageIDSeed); seed != "" {
|
|
return "imported-" + externalStableHash(provider + "\x00" + providerSessionID + "\x00" + seed)[:32]
|
|
}
|
|
return externalImportedMessageID(provider, providerSessionID, message.RawID, index)
|
|
}
|
|
|
|
func externalImportedTurnID(userMessageID string) string {
|
|
const messagePrefix = "imported-"
|
|
userMessageID = strings.TrimSpace(userMessageID)
|
|
if !strings.HasPrefix(userMessageID, messagePrefix) || len(userMessageID) == len(messagePrefix) {
|
|
return ""
|
|
}
|
|
return "imported-turn-" + strings.TrimPrefix(userMessageID, messagePrefix)
|
|
}
|
|
|
|
func externalImportedMessagePayload(message externalImportedMessage) map[string]any {
|
|
payload := clonePayload(message.Payload)
|
|
if payload == nil {
|
|
payload = map[string]any{}
|
|
}
|
|
if strings.TrimSpace(message.Kind) == "text" {
|
|
payload["text"] = message.Text
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func externalStableHash(input string) string {
|
|
sum := sha256.Sum256([]byte(input))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func externalSessionTitle(messages []externalImportedMessage) string {
|
|
for _, message := range messages {
|
|
if message.Role == "user" {
|
|
return truncateExternalTitle(message.Text)
|
|
}
|
|
}
|
|
return truncateExternalTitle(messages[0].Text)
|
|
}
|
|
|
|
func truncateExternalTitle(input string) string {
|
|
input = strings.Join(strings.Fields(input), " ")
|
|
const maxTitleRunes = 80
|
|
runes := []rune(input)
|
|
if len(runes) <= maxTitleRunes {
|
|
return input
|
|
}
|
|
return strings.TrimSpace(string(runes[:maxTitleRunes]))
|
|
}
|
|
|
|
func firstExternalMessageUnixMS(messages []externalImportedMessage) int64 {
|
|
for _, message := range messages {
|
|
if message.OccurredAtUnixMS > 0 {
|
|
return message.OccurredAtUnixMS
|
|
}
|
|
}
|
|
return time.Now().UnixMilli()
|
|
}
|
|
|
|
func lastExternalMessageUnixMS(messages []externalImportedMessage) int64 {
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
if messages[i].OccurredAtUnixMS > 0 {
|
|
return messages[i].OccurredAtUnixMS
|
|
}
|
|
}
|
|
return firstExternalMessageUnixMS(messages)
|
|
}
|
|
|
|
func normalizeExternalMessageRole(role string) string {
|
|
switch strings.TrimSpace(strings.ToLower(role)) {
|
|
case "user", "assistant", "tool":
|
|
return strings.TrimSpace(strings.ToLower(role))
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizeExternalMessageKind(kind string) string {
|
|
switch strings.TrimSpace(strings.ToLower(kind)) {
|
|
case "tool_call":
|
|
return "tool_call"
|
|
case "reasoning":
|
|
return "reasoning"
|
|
default:
|
|
return "text"
|
|
}
|
|
}
|
|
|
|
func normalizeExternalMessageStatus(status string) string {
|
|
switch strings.TrimSpace(strings.ToLower(status)) {
|
|
case "running", "completed", "failed", "canceled", "waiting":
|
|
return strings.TrimSpace(strings.ToLower(status))
|
|
default:
|
|
return "completed"
|
|
}
|
|
}
|