feat(session): /session export + import with portable .zotsession file

Lets one user hand a conversation off to another machine or
user. New slash command:

  /session                    picker with export / import rows
  /session export             defaults to ~/Downloads/<name>.zotsession
  /session export ~/foo       writes ~/foo.zotsession
  /session export ~/bar/x.zs  writes to that exact path (ext added if missing)
  /session import <path>      loads and switches to it

Exported file is the same jsonl the live session writes, with
the meta row rewritten to strip the source user's cwd. The
importer rotates the id and cwd to claim the copy, so the
imported session becomes a first-class entry in the current
user's sessions/ directory and shows up in /sessions,
/jump, and on-disk summaries like any other.

core/session_portable.go (new)
  - ExportSession(src, dst) string  returns the resolved
    output path. dst can be a file, a directory, or a bare
    name missing the .zotsession ext; all three shapes land
    somewhere sensible.
  - ImportSession(src, root, cwd, version) string  returns
    the newly-created session file path, ready for
    OpenSession.
  - firstUserPrompt() + slugify() build descriptive
    "20260420-080305-3f268850-say-hello-in-one-sentence.zotsession"
    filenames when exporting into a directory.

core/session_portable_test.go (new)
  - Full round trip: write → export → import into a
    different cwd → OpenSession → message payloads match.
  - Verifies the exported meta drops the original cwd.
  - Verifies the .zotsession extension is appended when
    missing from dst.

modes/session_ops_dialog.go (new)
  - Tiny picker matching the telegramDialog / logoutDialog
    shape: arrow keys, enter, esc. Two rows (export / import)
    with muted hint text.

modes/interactive.go
  - sessionOpsDialog field + constructor + key dispatch +
    render selector, identical boilerplate to the other small
    dialogs.
  - openSessionOpsDialog, doSessionOp, doSessionExport,
    doSessionImport. Export uses CurrentSessionPath (new
    config hook); import calls core.ImportSession then routes
    through the existing LoadSession so the agent switches to
    the new file.
  - defaultExportDir (~/Downloads → ~ → /tmp fallback),
    expandTilde, friendlyPath helpers.

cli.go
  - CurrentSessionPath: sess.Path getter wired into the
    interactive config.

slash_suggest.go + README
  - /session listed in the slash catalog and the README
    commands table, with a short description of the two
    direct forms.

Not wired into the session_dialog.go picker (which stays
resume-only); a later change could add "export this one"
directly from the picker rows if that's useful.
This commit is contained in:
patriceckhart 2026-04-20 10:04:33 +02:00
parent 9a32f9cf5c
commit ef80f9cd80
7 changed files with 800 additions and 32 deletions

View file

@ -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 <id>` 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 <path>`. Default export destination is `~/Downloads`. |
| `/jump` | Scroll the chat to a previous turn (or `/jump <text>` 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. |

View file

@ -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)
},

View file

@ -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 <path> or /session
// import <path>; 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
}

View file

@ -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
}

View file

@ -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"},
}

View file

@ -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-<first-prompt-excerpt>.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(), "-")
}

View file

@ -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))
}
}