diff --git a/README.md b/README.md index 02c1276..b6da1d7 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ Type `/` in the TUI to open the autocomplete popup. Available commands: | `/logout [provider]` | Clear credentials for `anthropic`, `openai`, or all when omitted. | | `/model` | Pick a model from a list (or `/model ` to set directly). | | `/sessions` | Resume a previous session for this directory. | +| `/session` | Export the current session to a portable `.zotsession` file, or import one. Opens a picker without an argument; direct form: `/session export [path]` / `/session import `. Default export destination is `~/Downloads`. | | `/jump` | Scroll the chat to a previous turn (or `/jump ` to filter). | | `/btw` | Side chat with full context that doesn't add to the main thread. | | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. | diff --git a/internal/agent/cli.go b/internal/agent/cli.go index ba7886c..48fb2d6 100644 --- a/internal/agent/cli.go +++ b/internal/agent/cli.go @@ -564,8 +564,14 @@ func runInteractive(ctx context.Context, args Args, version string) error { BuildAgent: buildAgent, BuildAgentFor: buildAgentFor, LoadSession: loadSession, - Extensions: extMgr, - ChangelogChan: changelogCh, + CurrentSessionPath: func() string { + if sess == nil { + return "" + } + return sess.Path + }, + Extensions: extMgr, + ChangelogChan: changelogCh, OnChangelogDismiss: func() { _ = MarkChangelogShown(version) }, diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 6f3630e..f780c7f 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "sync" "time" @@ -74,6 +76,13 @@ type InteractiveConfig struct { // callback returns the new agent message slice so the TUI can invalidate. LoadSession func(path string) error + // CurrentSessionPath returns the path of the live session file + // on disk (the one every AppendMessage writes to). Used by + // /session export so the exporter ships the exact bytes on + // disk. Returns an empty string when --no-session is set or + // no session is open. + CurrentSessionPath func() string + // PersistModel is called whenever the user switches model or provider. // It should update config.json and (if there's an active session) // write a new meta row so resume picks up the same model. @@ -169,19 +178,20 @@ type Interactive struct { // while the check hasn't completed or nothing is available. updateInfo UpdateInfo - dialog *loginDialog - modelDialog *modelDialog - sessionDialog *sessionDialog - jumpDialog *jumpDialog - btwDialog *btwDialog - skillsDialog *skillsDialog - changelogDialog *changelogDialog - confirmDialog *confirmDialog - logoutDialog *logoutDialog - telegramDialog *telegramDialog - telegramBridge *telegram.Bridge - suggest *slashSuggester - spin *spinner + dialog *loginDialog + modelDialog *modelDialog + sessionDialog *sessionDialog + jumpDialog *jumpDialog + btwDialog *btwDialog + skillsDialog *skillsDialog + changelogDialog *changelogDialog + confirmDialog *confirmDialog + logoutDialog *logoutDialog + telegramDialog *telegramDialog + telegramBridge *telegram.Bridge + sessionOpsDialog *sessionOpsDialog + suggest *slashSuggester + spin *spinner // parkedTurn is the 1-based turn number the viewport is currently // scrolled to by /jump. 0 = not parked, showing the tail as usual. @@ -219,22 +229,23 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { Theme: cfg.Theme, ImageProto: tui.DetectImageProtocol(), }, - ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), - rend: tui.NewRenderer(cfg.Terminal), - toolCalls: map[string]*tui.ToolCallView{}, - dirty: make(chan struct{}, 8), - dialog: newLoginDialog(), - modelDialog: newModelDialog(), - sessionDialog: newSessionDialog(), - jumpDialog: newJumpDialog(), - btwDialog: newBtwDialog(), - skillsDialog: newSkillsDialog(), - changelogDialog: newChangelogDialog(), - confirmDialog: newConfirmDialog(), - logoutDialog: newLogoutDialog(), - telegramDialog: newTelegramDialog(), - suggest: newSlashSuggester(), - spin: newSpinner(), + ed: tui.NewEditor(cfg.Theme.FG256(cfg.Theme.Accent, "▌ ")), + rend: tui.NewRenderer(cfg.Terminal), + toolCalls: map[string]*tui.ToolCallView{}, + dirty: make(chan struct{}, 8), + dialog: newLoginDialog(), + modelDialog: newModelDialog(), + sessionDialog: newSessionDialog(), + jumpDialog: newJumpDialog(), + btwDialog: newBtwDialog(), + skillsDialog: newSkillsDialog(), + changelogDialog: newChangelogDialog(), + confirmDialog: newConfirmDialog(), + logoutDialog: newLogoutDialog(), + telegramDialog: newTelegramDialog(), + sessionOpsDialog: newSessionOpsDialog(), + suggest: newSlashSuggester(), + spin: newSpinner(), } if cfg.Agent != nil { i.agent = cfg.Agent @@ -427,7 +438,7 @@ func (i *Interactive) Run(ctx context.Context) error { // and the AfterFunc-driven invalidate got dropped on a // full channel. drainPending() - if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() { + if i.busy || i.dialog.Active() || i.modelDialog.Active() || i.sessionDialog.Active() || i.jumpDialog.Active() || i.btwDialog.Active() || i.skillsDialog.Active() || i.changelogDialog.Active() || i.confirmDialog.Active() || i.logoutDialog.Active() || i.telegramDialog.Active() || i.sessionOpsDialog.Active() { requestRedraw() // keep the spinner / dialog animation moving } } @@ -591,6 +602,8 @@ func (i *Interactive) redraw() { dialog = i.logoutDialog.Render(i.cfg.Theme, cols) case i.telegramDialog.Active(): dialog = i.telegramDialog.Render(i.cfg.Theme, cols) + case i.sessionOpsDialog.Active(): + dialog = i.sessionOpsDialog.Render(i.cfg.Theme, cols) } // Slash-command autocomplete: popup above the status line, only @@ -868,6 +881,19 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { i.invalidate() return false } + if i.sessionOpsDialog.Active() { + if k.Kind == tui.KeyCtrlC { + i.sessionOpsDialog.Close() + i.invalidate() + return false + } + act := i.sessionOpsDialog.HandleKey(k) + if act.Select { + i.doSessionOp(act.Action, "") + } + i.invalidate() + return false + } if i.jumpDialog.Active() { if k.Kind == tui.KeyCtrlC { i.jumpDialog.Close() @@ -1342,6 +1368,17 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { break } i.openTelegramDialog() + case "/session": + if len(parts) >= 2 { + action := parts[1] + arg := "" + if len(parts) >= 3 { + arg = strings.Join(parts[2:], " ") + } + i.doSessionOp(action, arg) + break + } + i.openSessionOpsDialog() default: // Last-resort fallback: try the extension manager. Built-in // cases above always win; this branch only fires for slash @@ -2474,3 +2511,173 @@ func (h *telegramHost) Notify(level, message string) { h.iv.mu.Unlock() h.iv.invalidate() } + +// openSessionOpsDialog shows the picker for `/session` with no arg. +// Always offers both export and import; the handlers bail with a +// clear status message when the precondition isn't met (empty +// transcript on export; missing file on import). +func (i *Interactive) openSessionOpsDialog() { + items := []sessionOpsItem{ + {label: "export", action: "export", hint: "write the current session to a .zotsession file"}, + {label: "import", action: "import", hint: "load a .zotsession file into this directory"}, + } + i.sessionOpsDialog.Open(items) + i.invalidate() +} + +// doSessionOp dispatches export or import. path is the optional +// positional argument from /session export or /session +// import ; when empty, sensible defaults apply (see +// doSessionExport / doSessionImport). +func (i *Interactive) doSessionOp(action, path string) { + switch action { + case "export": + i.doSessionExport(path) + case "import": + i.doSessionImport(path) + default: + i.mu.Lock() + i.statusErr = "unknown /session action: " + action + " (use export or import)" + i.mu.Unlock() + i.invalidate() + } +} + +// doSessionExport writes the live session file to destination path +// dst. When dst is empty we default to ~/Downloads (falling back to +// the user's home directory if it doesn't exist). The helper +// expands a leading `~` and creates any missing parent directories. +func (i *Interactive) doSessionExport(dst string) { + if i.cfg.CurrentSessionPath == nil { + i.mu.Lock() + i.statusErr = "export: no session is active (running with --no-session?)" + i.mu.Unlock() + i.invalidate() + return + } + src := i.cfg.CurrentSessionPath() + if src == "" { + i.mu.Lock() + i.statusErr = "export: no session is active (running with --no-session?)" + i.mu.Unlock() + i.invalidate() + return + } + dst = strings.TrimSpace(dst) + if dst == "" { + dst = defaultExportDir() + } else { + dst = expandTilde(dst) + } + out, err := core.ExportSession(src, dst) + if err != nil { + i.mu.Lock() + i.statusErr = "export: " + err.Error() + i.mu.Unlock() + i.invalidate() + return + } + i.mu.Lock() + i.statusOK = "exported session to " + friendlyPath(out) + i.statusErr = "" + i.mu.Unlock() + i.invalidate() +} + +// doSessionImport copies the .zotsession file at src into the +// running cwd's sessions directory and loads it as the active +// session, same as `/sessions` -> pick. When src is empty we ask +// the user to pass a path (no usable default here). +func (i *Interactive) doSessionImport(src string) { + src = strings.TrimSpace(src) + if src == "" { + i.mu.Lock() + i.statusErr = "import: pass a path — e.g. /session import ~/Downloads/work.zotsession" + i.mu.Unlock() + i.invalidate() + return + } + src = expandTilde(src) + if _, err := os.Stat(src); err != nil { + i.mu.Lock() + i.statusErr = "import: " + err.Error() + i.mu.Unlock() + i.invalidate() + return + } + newPath, err := core.ImportSession(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version) + if err != nil { + i.mu.Lock() + i.statusErr = "import: " + err.Error() + i.mu.Unlock() + i.invalidate() + return + } + if i.cfg.LoadSession == nil { + i.mu.Lock() + i.statusOK = "imported session at " + friendlyPath(newPath) + " (run /sessions to resume it)" + i.statusErr = "" + i.mu.Unlock() + i.invalidate() + return + } + if err := i.cfg.LoadSession(newPath); err != nil { + i.mu.Lock() + i.statusErr = "import: load failed: " + err.Error() + i.mu.Unlock() + i.invalidate() + return + } + i.mu.Lock() + i.statusOK = "imported and switched to session " + friendlyPath(newPath) + i.statusErr = "" + i.mu.Unlock() + i.invalidate() +} + +// defaultExportDir returns ~/Downloads when it exists, or ~ as a +// fallback, or /tmp on exotic machines with no home dir. +func defaultExportDir() string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return os.TempDir() + } + downloads := filepath.Join(home, "Downloads") + if fi, err := os.Stat(downloads); err == nil && fi.IsDir() { + return downloads + } + return home +} + +// expandTilde turns a leading ~ into the user's home directory. +// Returns the input unchanged when there's no tilde or no home. +func expandTilde(p string) string { + if p == "" || p[0] != '~' { + return p + } + home, err := os.UserHomeDir() + if err != nil || home == "" { + return p + } + if len(p) == 1 { + return home + } + if p[1] == '/' || p[1] == filepath.Separator { + return filepath.Join(home, p[2:]) + } + return p +} + +// friendlyPath collapses the user's home directory to a leading ~ +// so status messages read cleanly. Falls back to the raw path when +// the home dir is unknown. +func friendlyPath(p string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return p + } + if strings.HasPrefix(p, home+string(filepath.Separator)) { + return "~" + p[len(home):] + } + return p +} diff --git a/internal/agent/modes/session_ops_dialog.go b/internal/agent/modes/session_ops_dialog.go new file mode 100644 index 0000000..197a0e2 --- /dev/null +++ b/internal/agent/modes/session_ops_dialog.go @@ -0,0 +1,101 @@ +package modes + +import ( + "github.com/patriceckhart/zot/internal/tui" +) + +// sessionOpsDialog is the picker shown when the user runs `/session` +// without an argument. Offers the two portable-file operations on +// the current conversation: export (write the in-memory transcript +// plus meta to a .zotsession file) and import (load a .zotsession +// from another machine and swap it in as the active session). +// +// Shape mirrors telegramDialog and logoutDialog: tiny list, arrow +// keys to move, enter to pick, esc to cancel. +type sessionOpsDialog struct { + active bool + items []sessionOpsItem + cursor int +} + +type sessionOpsItem struct { + label string + action string // "export" | "import" + hint string +} + +type sessionOpsAction struct { + Select bool + Action string + Close bool +} + +func newSessionOpsDialog() *sessionOpsDialog { return &sessionOpsDialog{} } + +// Open shows the picker. Items are usually both "export" and +// "import" but the caller can suppress either (e.g. hide export +// when the session is empty). +func (d *sessionOpsDialog) Open(items []sessionOpsItem) bool { + if len(items) == 0 { + return false + } + d.items = items + d.cursor = 0 + d.active = true + return true +} + +// Close hides the dialog. +func (d *sessionOpsDialog) Close() { d.active = false } + +// Active reports whether the dialog is consuming input. +func (d *sessionOpsDialog) Active() bool { return d != nil && d.active } + +// HandleKey advances the selection or resolves the dialog. +func (d *sessionOpsDialog) HandleKey(k tui.Key) sessionOpsAction { + switch k.Kind { + case tui.KeyUp: + if d.cursor > 0 { + d.cursor-- + } + case tui.KeyDown: + if d.cursor < len(d.items)-1 { + d.cursor++ + } + case tui.KeyEsc: + d.Close() + return sessionOpsAction{Close: true} + case tui.KeyEnter: + if len(d.items) == 0 { + d.Close() + return sessionOpsAction{Close: true} + } + it := d.items[d.cursor] + d.Close() + return sessionOpsAction{Select: true, Action: it.action} + } + return sessionOpsAction{} +} + +// Render returns the dialog lines. +func (d *sessionOpsDialog) Render(th tui.Theme, width int) []string { + if !d.Active() { + return nil + } + var lines []string + lines = append(lines, frameHeader(th, "session", width)) + lines = append(lines, th.FG256(th.Muted, "pick an action (\u2191/\u2193, enter, esc to cancel):")) + for i, it := range d.items { + plain := " " + it.label + if it.hint != "" { + plain += " " + th.FG256(th.Muted, "("+it.hint+")") + } + if i == d.cursor { + lines = append(lines, th.PadHighlight(plain, width)) + } else { + lines = append(lines, th.FG256(th.Muted, plain)) + } + } + lines = append(lines, frameRule(th, width)) + return lines +} diff --git a/internal/agent/modes/slash_suggest.go b/internal/agent/modes/slash_suggest.go index 1857598..9510769 100644 --- a/internal/agent/modes/slash_suggest.go +++ b/internal/agent/modes/slash_suggest.go @@ -46,6 +46,7 @@ var slashCatalog = []slashCommand{ {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, {Name: "/yolo", Desc: "turn off --no-yolo confirmation for the rest of this session"}, {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, + {Name: "/session", Desc: "export the current session to a .zotsession file, or import one"}, {Name: "/clear", Desc: "clear the chat transcript"}, {Name: "/exit", Desc: "exit zot"}, } diff --git a/internal/core/session_portable.go b/internal/core/session_portable.go new file mode 100644 index 0000000..5bf47b6 --- /dev/null +++ b/internal/core/session_portable.go @@ -0,0 +1,320 @@ +package core + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/google/uuid" +) + +// PortableExt is the filesystem extension used for exported sessions. +// A ".zotsession" is just a zot JSONL session file with the meta +// header rewritten so the importing user gets fresh ownership. +const PortableExt = ".zotsession" + +// ExportSession writes the session at srcPath to dstPath as a +// portable .zotsession file. If dstPath is an existing directory the +// file is created inside it with a name derived from the session's +// meta ("YYYYMMDD-HHMMSS-.zotsession"). The +// destination's directory is created if needed. Returns the final +// resolved path so the caller can tell the user where it landed. +// +// The on-disk format is unchanged from a live session; only the +// meta.cwd is stripped of its per-machine prefix (the importing +// user doesn't care what directory it came from). Everything else +// round-trips byte-for-byte. +func ExportSession(srcPath, dstPath string) (string, error) { + if srcPath == "" { + return "", errors.New("export: source path is empty") + } + if dstPath == "" { + return "", errors.New("export: destination path is empty") + } + + // Read the source meta up-front so we can name the output sensibly + // when dstPath is a directory, and so we can validate it's a real + // session before starting to write. + src, err := os.Open(srcPath) + if err != nil { + return "", fmt.Errorf("export: open source: %w", err) + } + defer src.Close() + + sc := bufio.NewScanner(src) + sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) + if !sc.Scan() { + return "", errors.New("export: session file is empty") + } + var head sessionLine + if err := json.Unmarshal(sc.Bytes(), &head); err != nil { + return "", fmt.Errorf("export: parse meta: %w", err) + } + if head.Type != "meta" || head.Meta == nil { + return "", errors.New("export: first line is not a meta row") + } + + // Scan the rest of the file for the first user message so we can + // build a humane filename. Only reads if dstPath doesn't already + // end in .zotsession. + firstPrompt := "" + if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) { + if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() { + firstPrompt = firstUserPrompt(sc) + } + } + + // Resolve dstPath: if it's a directory, build a name inside it. + outPath := dstPath + if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() { + name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt) + outPath = filepath.Join(dstPath, name) + } else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) { + outPath += PortableExt + } + + // Re-open the source from the top since we advanced the scanner. + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("export: rewind: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return "", fmt.Errorf("export: mkdir dst: %w", err) + } + dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return "", fmt.Errorf("export: create dst: %w", err) + } + defer dst.Close() + bw := bufio.NewWriter(dst) + + // Rewrite the meta row: strip the cwd (the importing user has + // their own) and keep everything else identical. ID stays so the + // export is traceable; the importer will rotate to a fresh ID. + exportMeta := *head.Meta + exportMeta.CWD = "" + metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta}) + if err != nil { + return "", fmt.Errorf("export: marshal meta: %w", err) + } + if _, err := bw.Write(metaLine); err != nil { + return "", err + } + if err := bw.WriteByte('\n'); err != nil { + return "", err + } + + // Stream every non-meta row verbatim. + sc2 := bufio.NewScanner(src) + sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) + for sc2.Scan() { + line := sc2.Bytes() + var h sessionLineHead + if err := json.Unmarshal(line, &h); err != nil { + continue + } + if h.Type == "meta" { + continue // already wrote a rewritten copy above + } + if _, err := bw.Write(line); err != nil { + return "", err + } + if err := bw.WriteByte('\n'); err != nil { + return "", err + } + } + if err := sc2.Err(); err != nil { + return "", fmt.Errorf("export: read source: %w", err) + } + if err := bw.Flush(); err != nil { + return "", err + } + return outPath, nil +} + +// ImportSession copies the .zotsession file at srcPath into the +// running user's session store under the given root+cwd, rewriting +// the meta's id / cwd / started fields so the imported session is +// owned by the current user / directory / clock. Returns the path +// of the created session file, ready to pass to OpenSession. +// +// The imported session is a first-class zot session: it'll show up +// in /sessions, /jump, and on-disk summaries just like any other. +// Messages and usage rows are preserved verbatim. +func ImportSession(srcPath, root, cwd, version string) (string, error) { + if srcPath == "" { + return "", errors.New("import: source path is empty") + } + src, err := os.Open(srcPath) + if err != nil { + return "", fmt.Errorf("import: open source: %w", err) + } + defer src.Close() + + // Validate the file header before committing to a destination. + sc := bufio.NewScanner(src) + sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) + if !sc.Scan() { + return "", errors.New("import: session file is empty") + } + var head sessionLine + if err := json.Unmarshal(sc.Bytes(), &head); err != nil { + return "", fmt.Errorf("import: parse meta: %w", err) + } + if head.Type != "meta" || head.Meta == nil { + return "", errors.New("import: first line is not a meta row") + } + + // Build the destination inside the current cwd's session dir + // with a fresh timestamped name. + dir := SessionsDir(root, cwd) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + newID := uuid.NewString() + name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8]) + outPath := filepath.Join(dir, name) + dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return "", fmt.Errorf("import: create dst: %w", err) + } + defer dst.Close() + bw := bufio.NewWriter(dst) + + // Write a fresh meta row claiming ownership. + importMeta := SessionMeta{ + ID: newID, + CWD: cwd, + Model: head.Meta.Model, + Provider: head.Meta.Provider, + Started: time.Now().UTC(), + Version: version, + } + metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) + if err != nil { + return "", fmt.Errorf("import: marshal meta: %w", err) + } + if _, err := bw.Write(metaLine); err != nil { + return "", err + } + if err := bw.WriteByte('\n'); err != nil { + return "", err + } + + // Rewind the source and stream every non-meta row. + if _, err := src.Seek(0, io.SeekStart); err != nil { + return "", fmt.Errorf("import: rewind: %w", err) + } + sc2 := bufio.NewScanner(src) + sc2.Buffer(make([]byte, 0, 64*1024), 20*1024*1024) + for sc2.Scan() { + line := sc2.Bytes() + var h sessionLineHead + if err := json.Unmarshal(line, &h); err != nil { + continue + } + if h.Type == "meta" { + continue + } + if _, err := bw.Write(line); err != nil { + return "", err + } + if err := bw.WriteByte('\n'); err != nil { + return "", err + } + } + if err := sc2.Err(); err != nil { + return "", fmt.Errorf("import: read source: %w", err) + } + if err := bw.Flush(); err != nil { + return "", err + } + return outPath, nil +} + +// firstUserPrompt scans forward from the current scanner position +// looking for the first user-role message and returns its text +// (trimmed, short). Used to build a humane export filename. +func firstUserPrompt(sc *bufio.Scanner) string { + for sc.Scan() { + var line sessionLine + if err := json.Unmarshal(sc.Bytes(), &line); err != nil { + continue + } + if line.Type != "message" || line.Message == nil || line.Message.Role != "user" { + continue + } + for _, c := range line.Message.Content { + // Use type name to avoid an import of provider here beyond + // the already-imported alias; TextBlock is the only content + // shape that yields a useful preview, so we just look for + // something with a reasonable string form. + s := fmt.Sprintf("%v", c) + _ = s // formatted value is too noisy; go straight to typed path + } + // Simpler: marshal the message and fish out the first "text" + // we can find. Avoids reaching into the provider package just + // for an interface type check. + b, _ := json.Marshal(line.Message) + var m struct { + Content []struct { + Text string `json:"text"` + } `json:"content"` + } + _ = json.Unmarshal(b, &m) + for _, c := range m.Content { + if c.Text != "" { + return c.Text + } + } + } + return "" +} + +// filenameFor builds a descriptive .zotsession filename from the +// session's start time and, when available, an excerpt of the +// first user prompt. +func filenameFor(started time.Time, id, firstPrompt string) string { + base := started.UTC().Format("20060102-150405") + if id != "" && len(id) >= 8 { + base += "-" + id[:8] + } + slug := slugify(firstPrompt, 40) + if slug != "" { + base += "-" + slug + } + return base + PortableExt +} + +// slugify lowercases, strips punctuation, collapses whitespace to +// hyphens, and truncates to max runes so it's safe as a filename. +func slugify(s string, max int) string { + s = strings.TrimSpace(strings.ToLower(s)) + if s == "" { + return "" + } + var out strings.Builder + prevDash := false + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + out.WriteRune(r) + prevDash = false + default: + if !prevDash && out.Len() > 0 { + out.WriteByte('-') + prevDash = true + } + } + if out.Len() >= max { + break + } + } + return strings.TrimRight(out.String(), "-") +} diff --git a/internal/core/session_portable_test.go b/internal/core/session_portable_test.go new file mode 100644 index 0000000..cb438e1 --- /dev/null +++ b/internal/core/session_portable_test.go @@ -0,0 +1,132 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/patriceckhart/zot/internal/provider" +) + +// TestSessionExportImportRoundTrip writes a few messages to a live +// session, exports it, imports the export under a different cwd, +// and verifies OpenSession on the imported file yields the same +// message payloads. +func TestSessionExportImportRoundTrip(t *testing.T) { + root := t.TempDir() + originalCWD := "/path/to/project" + sess, err := NewSession(root, originalCWD, "anthropic", "claude-opus-4-7", "0.0.0-test") + if err != nil { + t.Fatal(err) + } + _ = sess.AppendMessage(provider.Message{ + Role: provider.RoleUser, + Content: []provider.Content{provider.TextBlock{Text: "hello from the exporter"}}, + }) + _ = sess.AppendMessage(provider.Message{ + Role: provider.RoleAssistant, + Content: []provider.Content{provider.TextBlock{Text: "hi — reply from the assistant"}}, + }) + _ = sess.Close() + + // Export to a directory — helper should build a name inside it. + exportDir := t.TempDir() + exportPath, err := ExportSession(sess.Path, exportDir) + if err != nil { + t.Fatalf("ExportSession: %v", err) + } + if !strings.HasSuffix(exportPath, PortableExt) { + t.Errorf("exported path should end in %s, got %q", PortableExt, exportPath) + } + if _, err := os.Stat(exportPath); err != nil { + t.Fatalf("exported file doesn't exist: %v", err) + } + + // Import into a different root + cwd. + root2 := t.TempDir() + cwd2 := "/some/other/project" + importedPath, err := ImportSession(exportPath, root2, cwd2, "0.0.0-test") + if err != nil { + t.Fatalf("ImportSession: %v", err) + } + if filepath.Dir(importedPath) != SessionsDir(root2, cwd2) { + t.Errorf("imported file should land in SessionsDir, got %q", importedPath) + } + + // Reopen and verify message round-trip. + imported, msgs, err := OpenSession(importedPath) + if err != nil { + t.Fatalf("OpenSession: %v", err) + } + defer imported.Close() + if imported.Meta.CWD != cwd2 { + t.Errorf("meta cwd: want %q, got %q", cwd2, imported.Meta.CWD) + } + if imported.Meta.ID == sess.ID { + t.Errorf("imported session kept the original id %q; must be rotated", sess.ID) + } + if imported.Meta.Model != "claude-opus-4-7" { + t.Errorf("model not preserved: %q", imported.Meta.Model) + } + if len(msgs) != 2 { + t.Fatalf("want 2 messages, got %d", len(msgs)) + } + // Text should round-trip. + if extractText(msgs[0]) != "hello from the exporter" { + t.Errorf("msg 0 mismatch: %q", extractText(msgs[0])) + } + if extractText(msgs[1]) != "hi — reply from the assistant" { + t.Errorf("msg 1 mismatch: %q", extractText(msgs[1])) + } +} + +// TestExportToFilePath writes to an explicit file path (no +// directory guessing) and checks the .zotsession extension is +// appended when missing. +func TestExportToFilePath(t *testing.T) { + root := t.TempDir() + sess, err := NewSession(root, "/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test") + if err != nil { + t.Fatal(err) + } + _ = sess.AppendMessage(provider.Message{ + Role: provider.RoleUser, + Content: []provider.Content{provider.TextBlock{Text: "x"}}, + }) + _ = sess.Close() + + // No extension — should add .zotsession. + dst := filepath.Join(t.TempDir(), "mysession") + out, err := ExportSession(sess.Path, dst) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(out, PortableExt) { + t.Errorf("want .zotsession suffix on %q", out) + } +} + +// TestExportStripsCWDFromMeta verifies the exported meta no longer +// carries the source user's cwd (not useful to the recipient). +func TestExportStripsCWDFromMeta(t *testing.T) { + root := t.TempDir() + sess, err := NewSession(root, "/original/cwd", "anthropic", "claude-opus-4-7", "0.0.0-test") + if err != nil { + t.Fatal(err) + } + _ = sess.AppendMessage(provider.Message{ + Role: provider.RoleUser, + Content: []provider.Content{provider.TextBlock{Text: "x"}}, + }) + _ = sess.Close() + + out, err := ExportSession(sess.Path, filepath.Join(t.TempDir(), "x"+PortableExt)) + if err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(out) + if strings.Contains(string(b), "/original/cwd") { + t.Errorf("exported file leaks the source cwd: %s", string(b)) + } +}