1package core
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "os"
12 "path/filepath"
13 "sort"
14 "strings"
15 "time"
16
17 "github.com/bnema/zut/packages/provider"
18 "github.com/google/uuid"
19)
20
21// PortableExt is the filesystem extension used for exported sessions.
22// A ".zutsession" is just a zut JSONL session file with the meta
23// header rewritten so the importing user gets fresh ownership.
24const PortableExt = ".zutsession"
25
26// ExportSession writes the session at srcPath to dstPath as a
27// portable .zutsession file. If dstPath is an existing directory the
28// file is created inside it with a name derived from the session's
29// meta ("YYYYMMDD-HHMMSS-<first-prompt-excerpt>.zutsession"). The
30// destination's directory is created if needed. Returns the final
31// resolved path so the caller can tell the user where it landed.
32//
33// The on-disk format is unchanged from a live session; only the
34// meta.cwd is stripped of its per-machine prefix (the importing
35// user doesn't care what directory it came from). Everything else
36// round-trips byte-for-byte.
37func ExportSession(srcPath, dstPath string) (string, error) {
38 if srcPath == "" {
39 return "", errors.New("export: source path is empty")
40 }
41 if dstPath == "" {
42 return "", errors.New("export: destination path is empty")
43 }
44
45 // Read the effective snapshot so a later model/provider update is not
46 // replaced by the stale metadata in the first row.
47 snapshot, err := ReadSessionSnapshot(srcPath)
48 if err != nil {
49 return "", fmt.Errorf("export: read session snapshot: %w", err)
50 }
51
52 // Read the source meta up-front so we can name the output sensibly
53 // when dstPath is a directory, and so we can validate it's a real
54 // session before starting to write.
55 src, err := os.Open(srcPath)
56 if err != nil {
57 return "", fmt.Errorf("export: open source: %w", err)
58 }
59 defer src.Close()
60
61 sc := bufio.NewScanner(src)
62 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
63 if !sc.Scan() {
64 return "", errors.New("export: session file is empty")
65 }
66 var head sessionLine
67 if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
68 return "", fmt.Errorf("export: parse meta: %w", err)
69 }
70 if head.Type != "meta" || head.Meta == nil {
71 return "", errors.New("export: first line is not a meta row")
72 }
73
74 // Scan the rest of the file for the first user message so we can
75 // build a humane filename. Only reads if dstPath doesn't already
76 // end in .zutsession.
77 firstPrompt := ""
78 if !strings.HasSuffix(strings.ToLower(dstPath), PortableExt) {
79 if fi, _ := os.Stat(dstPath); fi == nil || fi.IsDir() {
80 p, err := firstUserPrompt(src)
81 if err != nil {
82 return "", fmt.Errorf("export: read first prompt: %w", err)
83 }
84 firstPrompt = p
85 }
86 }
87
88 // Resolve dstPath: if it's a directory, build a name inside it.
89 outPath := dstPath
90 if fi, err := os.Stat(dstPath); err == nil && fi.IsDir() {
91 name := filenameFor(head.Meta.Started, head.Meta.ID, firstPrompt)
92 outPath = filepath.Join(dstPath, name)
93 } else if !strings.HasSuffix(strings.ToLower(outPath), PortableExt) {
94 outPath += PortableExt
95 }
96
97 // Re-open the source from the top since we advanced the scanner.
98 if _, err := src.Seek(0, io.SeekStart); err != nil {
99 return "", fmt.Errorf("export: rewind: %w", err)
100 }
101
102 if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
103 return "", fmt.Errorf("export: mkdir dst: %w", err)
104 }
105 dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
106 if err != nil {
107 return "", fmt.Errorf("export: create dst: %w", err)
108 }
109 defer dst.Close()
110 bw := bufio.NewWriter(dst)
111
112 // Rewrite the meta row: strip the cwd (the importing user has
113 // their own) and keep everything else identical. ID stays so the
114 // export is traceable; the importer will rotate to a fresh ID.
115 exportMeta := snapshot.Meta
116 exportMeta.CWD = ""
117 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &exportMeta})
118 if err != nil {
119 return "", fmt.Errorf("export: marshal meta: %w", err)
120 }
121 if _, err := bw.Write(metaLine); err != nil {
122 return "", err
123 }
124 if err := bw.WriteByte('\n'); err != nil {
125 return "", err
126 }
127
128 // Stream every non-meta row verbatim. Use ReadBytes instead of
129 // bufio.Scanner: large sessions can contain very long JSONL rows
130 // (image blocks, big tool outputs, compacted history) that exceed
131 // Scanner's token limit and fail with "token too long".
132 r := bufio.NewReader(src)
133 for {
134 line, err := r.ReadBytes('\n')
135 if len(line) > 0 {
136 line = bytes.TrimRight(line, "\r\n")
137 var h sessionLineHead
138 if err := json.Unmarshal(line, &h); err == nil && h.Type != "meta" {
139 if _, werr := bw.Write(line); werr != nil {
140 return "", werr
141 }
142 if werr := bw.WriteByte('\n'); werr != nil {
143 return "", werr
144 }
145 }
146 }
147 if err == io.EOF {
148 break
149 }
150 if err != nil {
151 return "", fmt.Errorf("export: read source: %w", err)
152 }
153 }
154 if err := bw.Flush(); err != nil {
155 return "", err
156 }
157 return outPath, nil
158}
159
160// ImportSession copies the .zutsession file at srcPath into the
161// running user's session store under the given root+cwd, rewriting
162// the meta's id / cwd / started fields so the imported session is
163// owned by the current user / directory / clock. Returns the path
164// of the created session file, ready to pass to OpenSession.
165//
166// The imported session is a first-class zut session: it'll show up
167// in /sessions, /jump, and on-disk summaries just like any other.
168// Messages and usage rows are preserved verbatim.
169func ImportSession(srcPath, root, cwd, version string) (string, error) {
170 if srcPath == "" {
171 return "", errors.New("import: source path is empty")
172 }
173 src, err := os.Open(srcPath)
174 if err != nil {
175 return "", fmt.Errorf("import: open source: %w", err)
176 }
177 defer src.Close()
178
179 // Read the effective metadata before committing to a destination. Meta
180 // rows are append-only, so the first row may describe an older provider or
181 // model after a runtime switch.
182 snapshot, err := ReadSessionSnapshot(srcPath)
183 if err != nil {
184 return "", fmt.Errorf("import: read session snapshot: %w", err)
185 }
186
187 // Validate the file header before committing to a destination.
188 sc := bufio.NewScanner(src)
189 sc.Buffer(make([]byte, 0, 64*1024), 20*1024*1024)
190 if !sc.Scan() {
191 return "", errors.New("import: session file is empty")
192 }
193 var head sessionLine
194 if err := json.Unmarshal(sc.Bytes(), &head); err != nil {
195 return "", fmt.Errorf("import: parse meta: %w", err)
196 }
197 if head.Type != "meta" || head.Meta == nil {
198 return "", errors.New("import: first line is not a meta row")
199 }
200
201 // Build the destination inside the current cwd's session dir
202 // with a fresh timestamped name.
203 dir := SessionsDir(root, cwd)
204 if err := os.MkdirAll(dir, 0o755); err != nil {
205 return "", err
206 }
207 newID := uuid.NewString()
208 name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
209 outPath := filepath.Join(dir, name)
210 dst, err := os.OpenFile(outPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
211 if err != nil {
212 return "", fmt.Errorf("import: create dst: %w", err)
213 }
214 defer dst.Close()
215 bw := bufio.NewWriter(dst)
216
217 // Write a fresh meta row claiming ownership.
218 now := time.Now()
219 timezone, timezoneOffset := localTimeMetadata(now)
220 importMeta := SessionMeta{
221 ID: newID,
222 CWD: cwd,
223 Model: snapshot.Meta.Model,
224 Provider: snapshot.Meta.Provider,
225 Started: now.UTC(),
226 Version: version,
227 Title: snapshot.Title,
228 Timezone: timezone,
229 TimezoneOffset: timezoneOffset,
230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231 Mission: cloneSessionMission(snapshot.Meta.Mission),
232 Goal: cloneSessionGoal(snapshot.Meta.Goal),
233 GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory),
234 Plan: cloneSessionPlan(snapshot.Meta.Plan),
235 }
236 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
237 if err != nil {
238 return "", fmt.Errorf("import: marshal meta: %w", err)
239 }
240 if _, err := bw.Write(metaLine); err != nil {
241 return "", err
242 }
243 if err := bw.WriteByte('\n'); err != nil {
244 return "", err
245 }
246
247 // Rewind the source and stream every non-meta row. Avoid
248 // bufio.Scanner so exported sessions with huge JSONL rows import
249 // cleanly.
250 if _, err := src.Seek(0, io.SeekStart); err != nil {
251 return "", fmt.Errorf("import: rewind: %w", err)
252 }
253 if err := forEachJSONLLine(src, func(line []byte) error {
254 var h sessionLineHead
255 if err := json.Unmarshal(line, &h); err != nil || h.Type == "meta" {
256 return nil
257 }
258 if _, err := bw.Write(line); err != nil {
259 return err
260 }
261 return bw.WriteByte('\n')
262 }); err != nil {
263 return "", fmt.Errorf("import: read source: %w", err)
264 }
265 if err := bw.Flush(); err != nil {
266 return "", err
267 }
268 return outPath, nil
269}
270
271// BranchSession creates a new session in root/cwd that contains the
272// parent's messages 0..upToMessageIdx-1 (i.e. the first N user+
273// assistant+tool rows). The new meta records Parent=<parent id> and
274// ForkPoint=N so /session tree can rebuild the branch topology
275// later. The effective repaired prefix is materialized, and all eligible
276// usage checkpoints at or before the cut are preserved so the running cost
277// tracker can reconstruct the final-turn delta. A boundary that ends on a
278// tool call is extended through its paired tool result.
279//
280// upToMessageIdx is a count over the flat message stream as
281// returned by OpenSession. To "branch at user turn 3" the caller
282// passes the index of that user message in msgs + 1 (so the
283// message itself is included). The caller figures that out; this
284// helper rejects a count outside the current effective snapshot.
285//
286// Returns the path of the new session file, ready for OpenSession.
287func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
288 return branchSession(context.Background(), parentPath, root, cwd, version, upToMessageIdx, false)
289}
290
291// BranchSessionHidden creates a branch that participates in /session tree but
292// is hidden from the flat /sessions picker. Used for in-place tree navigation.
293func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
294 return branchSession(context.Background(), parentPath, root, cwd, version, upToMessageIdx, true)
295}
296
297func branchSession(ctx context.Context, parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
298 if parentPath == "" {
299 return "", errors.New("branch: parent path is empty")
300 }
301 if upToMessageIdx < 0 {
302 return "", errors.New("branch: upToMessageIdx must be >= 0")
303 }
304
305 snapshot, err := readSessionSnapshot(ctx, parentPath)
306 if err != nil {
307 return "", fmt.Errorf("branch: read parent snapshot: %w", err)
308 }
309 if upToMessageIdx > len(snapshot.Messages) {
310 return "", fmt.Errorf("branch: upToMessageIdx %d exceeds effective message count %d", upToMessageIdx, len(snapshot.Messages))
311 }
312
313 // A tool-call row is not a safe provider boundary by itself. Accept the
314 // convenient row index used by existing callers, but extend it through the
315 // immediately paired tool result when necessary. Orphan calls already have
316 // a synthetic result in the shared snapshot.
317 limit := upToMessageIdx
318 if limit > 0 && limit < len(snapshot.Messages) && messageHasToolCalls(snapshot.Messages[limit-1]) && snapshot.Messages[limit].Role == provider.RoleTool {
319 limit++
320 }
321
322 extensionState, err := readExtensionStateAtFork(ctx, parentPath, limit)
323 if err != nil {
324 return "", err
325 }
326 compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit)
327 goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit)
328 return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal)
329}
330
331// BranchSessionHiddenFromHistory creates a hidden tree branch from a
332// pre-compaction transcript segment. The segment is already repaired and is
333// kept separate from the effective snapshot so normal resume semantics remain
334// unchanged while older fork points stay usable.
335func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segment SessionHistorySegment, upToMessageIdx int) (string, error) {
336 if parentPath == "" {
337 return "", errors.New("branch: parent path is empty")
338 }
339 if upToMessageIdx < 0 {
340 return "", errors.New("branch: upToMessageIdx must be >= 0")
341 }
342 if upToMessageIdx > len(segment.Messages) {
343 return "", fmt.Errorf("branch: upToMessageIdx %d exceeds historical message count %d", upToMessageIdx, len(segment.Messages))
344 }
345
346 snapshot, err := ReadSessionSnapshot(parentPath)
347 if err != nil {
348 return "", fmt.Errorf("branch: read parent snapshot: %w", err)
349 }
350 limit := upToMessageIdx
351 if limit > 0 && limit < len(segment.Messages) && messageHasToolCalls(segment.Messages[limit-1]) && segment.Messages[limit].Role == provider.RoleTool {
352 limit++
353 }
354 extensionState, err := readExtensionStateAtFork(context.Background(), parentPath, limit)
355 if err != nil {
356 return "", err
357 }
358 return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil)
359}
360
361// readExtensionStateAtFork reconstructs the latest extension snapshot that was
362// persisted at or before a branch boundary. Extension state is independent of
363// provider messages, but its timeline still follows the effective message
364// stream across compaction rows.
365func readExtensionStateAtFork(ctx context.Context, path string, limit int) (map[string]json.RawMessage, error) {
366 f, err := os.Open(path)
367 if err != nil {
368 return nil, fmt.Errorf("branch: open parent for extension state: %w", err)
369 }
370 defer f.Close()
371
372 state := make(map[string]json.RawMessage)
373 forkState := make(map[string]json.RawMessage)
374 cloneState := func(source map[string]json.RawMessage) map[string]json.RawMessage {
375 clone := make(map[string]json.RawMessage, len(source))
376 for name, raw := range source {
377 clone[name] = append(json.RawMessage(nil), raw...)
378 }
379 return clone
380 }
381 applyState := func(target map[string]json.RawMessage, row sessionLogRow) {
382 if len(row.state) == 0 || strings.TrimSpace(string(row.state)) == "null" {
383 delete(target, row.extension)
384 return
385 }
386 if json.Valid(row.state) {
387 target[row.extension] = append(json.RawMessage(nil), row.state...)
388 }
389 }
390 effectiveCount := 0
391 err = forEachSessionLogRowContext(ctx, f, func(row sessionLogRow) error {
392 switch row.typeName {
393 case "message":
394 effectiveCount++
395 case "compaction":
396 effectiveCount = len(row.messages)
397 // A compaction replaces the effective transcript. State that was
398 // recorded anywhere before this boundary belongs to every fork
399 // inside the replacement transcript, even when its old message
400 // index was after the requested fork point.
401 forkState = cloneState(state)
402 case "extension_state":
403 if row.extension == "" || len(row.state) > maxExtensionStateBytes {
404 return nil
405 }
406 applyState(state, row)
407 if effectiveCount <= limit {
408 applyState(forkState, row)
409 }
410 }
411 return nil
412 })
413 if err != nil {
414 return nil, fmt.Errorf("branch: read extension state: %w", err)
415 }
416 return forkState, nil
417}
418
419func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, limit int) json.RawMessage {
420 if len(parent.CompactHandoff) == 0 || limit != len(messages) {
421 return nil
422 }
423 return append(json.RawMessage(nil), parent.CompactHandoff...)
424}
425
426func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal {
427 if limit != len(messages) {
428 return nil
429 }
430 return cloneSessionGoal(parent.Goal)
431}
432
433func missionAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionMission {
434 if limit != len(messages) {
435 return nil
436 }
437 return cloneSessionMission(parent.Mission)
438}
439
440func cloneSessionGoal(goal *SessionGoal) *SessionGoal {
441 if goal == nil {
442 return nil
443 }
444 clone := *goal
445 if goal.TokenBudget != nil {
446 budget := *goal.TokenBudget
447 clone.TokenBudget = &budget
448 }
449 return &clone
450}
451
452func cloneGoalHistory(history []SessionGoal) []SessionGoal {
453 if len(history) == 0 {
454 return nil
455 }
456 clone := make([]SessionGoal, len(history))
457 for index := range history {
458 clone[index] = *cloneSessionGoal(&history[index])
459 }
460 return clone
461}
462
463func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal {
464 if limit != len(messages) {
465 return nil
466 }
467 return cloneGoalHistory(parent.GoalHistory)
468}
469
470// cloneSessionPlan returns an independent copy of plan, or nil.
471func cloneSessionPlan(plan *SessionPlan) *SessionPlan {
472 if plan == nil {
473 return nil
474 }
475 clone := *plan
476 clone.Steps = clonePlanSteps(plan.Steps)
477 return &clone
478}
479
480// planAtBranch reconstructs the agent-owned plan as it stood at a fork point.
481//
482// A fork at the end of the effective transcript keeps the parent's persisted
483// plan: it is what the host recorded, and it is the only surviving record when
484// the parent was compacted and its plan calls are gone.
485//
486// A fork that cuts the transcript short replays the plan calls inside that prefix
487// and reaches back no further, so the branch never inherits work its own
488// transcript does not contain — the rule goals already follow. Replay skips calls
489// whose paired tool result failed, was denied, or is missing: those calls left a
490// transcript row but changed nothing.
491func planAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionPlan {
492 if limit > len(messages) {
493 limit = len(messages)
494 }
495 if limit == len(messages) {
496 return cloneSessionPlan(parent.Plan)
497 }
498 steps, _ := replayPlanOps(messages[:limit])
499 if len(steps) == 0 {
500 return nil
501 }
502 // Normalize the replay result so a hand-edited or legacy transcript cannot
503 // seed a branch with steps the live agent could never hold.
504 return normalizeSessionPlan(&SessionPlan{Steps: steps})
505}
506
507// PlanUpdateSnapshots reconstructs the per-call checklist snapshots the host
508// renders from a transcript. The transcript stores only per-call deltas, so the
509// materialized list shown for a historical plan call cannot be read back from
510// its arguments; it must be rebuilt by applying each accepted operation in
511// order. The result is keyed by tool-call id. Both the legacy update_plan
512// full-list shape and the plan action shape are accepted, and calls whose paired
513// tool result failed, was denied, or is missing are skipped, matching
514// planAtBranch.
515func PlanUpdateSnapshots(messages []provider.Message) map[string]PlanUpdate {
516 _, snapshots := replayPlanOps(messages)
517 return snapshots
518}
519
520// replayPlanOps applies every successful plan call in segment in order and
521// returns the final steps plus the materialized checklist after each applied
522// call, keyed by tool-call id. Read-only or invalid operations do not change
523// state and therefore produce no snapshot.
524func replayPlanOps(segment []provider.Message) ([]PlanStep, map[string]PlanUpdate) {
525 applied := planToolResults(segment)
526 var steps []PlanStep
527 var snapshots map[string]PlanUpdate
528 for idx := range segment {
529 for _, content := range segment[idx].Content {
530 call, ok := content.(provider.ToolCallBlock)
531 if !ok || !applied[call.ID] {
532 continue
533 }
534 op, ok := planOperationFromToolCall(call.Name, call.Arguments)
535 if !ok {
536 continue
537 }
538 next, changed := applyPlanOperation(steps, op)
539 if !changed {
540 continue
541 }
542 steps = next
543 if snapshots == nil {
544 snapshots = make(map[string]PlanUpdate)
545 }
546 snapshots[call.ID] = PlanUpdate{Explanation: op.Explanation, Plan: clonePlanSteps(steps)}
547 }
548 }
549 return steps, snapshots
550}
551
552// planToolResults maps the call ids in segment to whether their tool result
553// reports success. A call whose result is an error, or that has no result at all
554// because the turn ended first, never changed the plan.
555func planToolResults(segment []provider.Message) map[string]bool {
556 applied := make(map[string]bool)
557 for idx := range segment {
558 for _, content := range segment[idx].Content {
559 result, ok := content.(provider.ToolResultBlock)
560 if !ok || result.CallID == "" {
561 continue
562 }
563 applied[result.CallID] = !result.IsError
564 }
565 }
566 return applied
567}
568
569const (
570 // planToolName is the current plan tool name. Core cannot import
571 // packages/agent/tools, so the wire name is repeated here for transcript
572 // replay.
573 planToolName = "plan"
574 // legacyPlanToolName is the pre-rename checklist tool. Historical sessions
575 // still carry its calls.
576 legacyPlanToolName = "update_plan"
577)
578
579type planToolStep struct {
580 Step *string `json:"step"`
581 Status *string `json:"status"`
582}
583
584// planOperationFromToolCall decodes one plan-affecting tool call. The legacy
585// update_plan shape carries a full list with replace semantics, so it maps to a
586// set operation; the new shape carries an explicit action.
587func planOperationFromToolCall(name string, args json.RawMessage) (PlanOperation, bool) {
588 switch name {
589 case legacyPlanToolName:
590 var legacy struct {
591 Explanation *string `json:"explanation"`
592 Plan *[]planToolStep `json:"plan"`
593 }
594 if err := json.Unmarshal(args, &legacy); err != nil || legacy.Plan == nil {
595 return PlanOperation{}, false
596 }
597 steps, ok := planToolSteps(*legacy.Plan)
598 if !ok {
599 return PlanOperation{}, false
600 }
601 return PlanOperation{Action: "set", Explanation: legacy.Explanation, Steps: steps}, true
602 case planToolName:
603 var raw struct {
604 Action string `json:"action"`
605 Explanation *string `json:"explanation"`
606 Steps []planToolStep `json:"steps"`
607 Index int `json:"index"`
608 Status string `json:"status"`
609 Step *string `json:"step"`
610 }
611 if err := json.Unmarshal(args, &raw); err != nil {
612 return PlanOperation{}, false
613 }
614 steps, ok := planToolSteps(raw.Steps)
615 if !ok {
616 return PlanOperation{}, false
617 }
618 return PlanOperation{
619 Action: raw.Action,
620 Explanation: raw.Explanation,
621 Steps: steps,
622 Index: raw.Index,
623 Text: raw.Step,
624 Status: PlanStepStatus(raw.Status),
625 }, true
626 default:
627 return PlanOperation{}, false
628 }
629}
630
631func planToolSteps(raw []planToolStep) ([]PlanStep, bool) {
632 if len(raw) == 0 {
633 return nil, true
634 }
635 steps := make([]PlanStep, 0, len(raw))
636 for _, step := range raw {
637 if step.Step == nil {
638 return nil, false
639 }
640 steps = append(steps, PlanStep{Step: *step.Step, Status: planToolStatus(step.Status)})
641 }
642 return steps, true
643}
644
645// planToolStatus defaults an omitted status to pending, matching the schema's
646// optional status on new-shape steps.
647func planToolStatus(status *string) PlanStepStatus {
648 if status == nil {
649 return PlanPending
650 }
651 return PlanStepStatus(*status)
652}
653
654// applyPlanOperation replays op against steps and reports whether it changed
655// state. Invalid or read-only operations leave steps untouched.
656func applyPlanOperation(steps []PlanStep, op PlanOperation) ([]PlanStep, bool) {
657 switch op.Action {
658 case "set":
659 return clonePlanSteps(op.Steps), true
660 case "add":
661 if len(op.Steps) == 0 {
662 return steps, false
663 }
664 return append(clonePlanSteps(steps), clonePlanSteps(op.Steps)...), true
665 case "update":
666 if op.Index < 1 || op.Index > len(steps) {
667 return steps, false
668 }
669 if op.Text != nil {
670 steps[op.Index-1].Step = *op.Text
671 }
672 if op.Status != "" {
673 steps[op.Index-1].Status = op.Status
674 }
675 return steps, true
676 case "remove":
677 if op.Index < 1 || op.Index > len(steps) {
678 return steps, false
679 }
680 return append(steps[:op.Index-1:op.Index-1], steps[op.Index:]...), true
681 case "clear":
682 return nil, true
683 default:
684 return steps, false
685 }
686}
687
688func writeBranchSession(root, cwd, version string, parent SessionMeta, messages []provider.Message, checkpoints []SessionUsageCheckpoint, limit int, hideFromSessions bool, extensionState map[string]json.RawMessage, compactHandoff json.RawMessage, goal *SessionGoal) (string, error) {
689 dir := SessionsDir(root, cwd)
690 if err := os.MkdirAll(dir, 0o755); err != nil {
691 return "", err
692 }
693 newID := uuid.NewString()
694 now := time.Now()
695 timezone, timezoneOffset := localTimeMetadata(now)
696 name := fmt.Sprintf("%s-%s.jsonl", now.UTC().Format("20060102-150405"), newID[:8])
697 outPath := filepath.Join(dir, name)
698 // Build in a same-directory temporary file. Rename is atomic only when
699 // the temporary and final paths share a directory; closing the file first
700 // also ensures no buffered writer or descriptor can expose a partial branch.
701 dst, err := os.CreateTemp(dir, ".zut-branch-*.tmp")
702 if err != nil {
703 return "", fmt.Errorf("branch: create temp: %w", err)
704 }
705 tmpPath := dst.Name()
706 if err := dst.Chmod(0o644); err != nil {
707 _ = dst.Close()
708 _ = os.Remove(tmpPath)
709 return "", fmt.Errorf("branch: chmod temp: %w", err)
710 }
711 committed := false
712 defer func() {
713 _ = dst.Close()
714 if !committed {
715 _ = os.Remove(tmpPath)
716 _ = os.Remove(outPath)
717 }
718 }()
719 bw := bufio.NewWriter(dst)
720
721 branchMeta := SessionMeta{
722 ID: newID,
723 CWD: cwd,
724 Model: parent.Model,
725 Provider: parent.Provider,
726 Started: now.UTC(),
727 Version: version,
728 Timezone: timezone,
729 TimezoneOffset: timezoneOffset,
730 Parent: parent.ID,
731 ForkPoint: limit,
732 HideFromSessions: hideFromSessions,
733 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
734 Mission: missionAtBranch(parent, messages, limit),
735 Goal: cloneSessionGoal(goal),
736 GoalHistory: goalHistoryAtBranch(parent, messages, limit),
737 Plan: planAtBranch(parent, messages, limit),
738 }
739 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
740 if err != nil {
741 return "", fmt.Errorf("branch: marshal meta: %w", err)
742 }
743 if _, err := bw.Write(metaLine); err != nil {
744 return "", err
745 }
746 if err := bw.WriteByte('\n'); err != nil {
747 return "", err
748 }
749
750 for idx := 0; idx < limit; idx++ {
751 msg := messages[idx]
752 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
753 if err != nil {
754 return "", fmt.Errorf("branch: marshal message %d: %w", idx, err)
755 }
756 if _, err := bw.Write(line); err != nil {
757 return "", err
758 }
759 if err := bw.WriteByte('\n'); err != nil {
760 return "", err
761 }
762 }
763
764 // Extension snapshots are branch state, not provider-visible transcript
765 // content. Write the latest snapshot that existed at the fork point so a
766 // child branch starts from the same extension state as its copied prefix.
767 extensionNames := make([]string, 0, len(extensionState))
768 for name := range extensionState {
769 extensionNames = append(extensionNames, name)
770 }
771 sort.Strings(extensionNames)
772 for _, name := range extensionNames {
773 line, err := json.Marshal(sessionLine{Type: "extension_state", Extension: name, State: extensionState[name]})
774 if err != nil {
775 return "", fmt.Errorf("branch: marshal extension state: %w", err)
776 }
777 if _, err := bw.Write(line); err != nil {
778 return "", fmt.Errorf("branch: write extension state: %w", err)
779 }
780 if err := bw.WriteByte('\n'); err != nil {
781 return "", fmt.Errorf("branch: terminate extension state row: %w", err)
782 }
783 }
784 // Preserve every cumulative checkpoint that belongs to this prefix. The
785 // final two rows are needed to reconstruct LastTurnUsage as a delta.
786 for _, checkpoint := range checkpoints {
787 if checkpoint.MessageCount > limit {
788 continue
789 }
790 usageLine, err := json.Marshal(sessionLine{
791 Type: "usage",
792 Usage: &checkpoint.Cumulative,
793 Cumulative: &checkpoint.Cumulative,
794 })
795 if err != nil {
796 return "", fmt.Errorf("branch: marshal usage: %w", err)
797 }
798 if _, err := bw.Write(usageLine); err != nil {
799 return "", err
800 }
801 if err := bw.WriteByte('\n'); err != nil {
802 return "", err
803 }
804 }
805
806 if err := bw.Flush(); err != nil {
807 return "", err
808 }
809 if err := dst.Sync(); err != nil {
810 return "", fmt.Errorf("branch: sync temp: %w", err)
811 }
812 if err := dst.Close(); err != nil {
813 return "", fmt.Errorf("branch: close temp: %w", err)
814 }
815 if err := os.Rename(tmpPath, outPath); err != nil {
816 return "", fmt.Errorf("branch: commit: %w", err)
817 }
818 committed = true
819 return outPath, nil
820}
821
822func lastUserText(msgs []provider.Message) string {
823 for i := len(msgs) - 1; i >= 0; i-- {
824 if msgs[i].Role != provider.RoleUser {
825 continue
826 }
827 if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
828 return text
829 }
830 }
831 return ""
832}
833
834// TreeNode is one entry in the branch tree returned by
835// BuildSessionTree. Children are populated by linking on Parent ID.
836type TreeNode struct {
837 Summary SessionSummary
838 Meta SessionMeta
839 Children []*TreeNode
840}
841
842// BuildSessionTree loads every session in the cwd dir and returns
843// the forest rooted at parentless sessions, with each non-root
844// session placed under its parent. Used by /session tree to render
845// the branch hierarchy.
846func BuildSessionTree(root, cwd string) []*TreeNode {
847 dir := SessionsDir(root, cwd)
848 entries, err := os.ReadDir(dir)
849 if err != nil {
850 return nil
851 }
852 nodes := make(map[string]*TreeNode)
853 order := []string{}
854 for _, e := range entries {
855 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
856 continue
857 }
858 path := filepath.Join(dir, e.Name())
859 summary := describeSession(path)
860 meta, _ := readSessionMeta(path)
861 if meta.ID == "" {
862 continue
863 }
864 nodes[meta.ID] = &TreeNode{Summary: summary, Meta: meta}
865 order = append(order, meta.ID)
866 }
867 return linkSessionTreeNodes(nodes, order)
868}
869
870// BuildSessionTreeStrict is the complete-read counterpart to the permissive
871// forest builder. It validates every session file before linking any node, so
872// callers that need a complete forest cannot silently omit malformed members.
873func BuildSessionTreeStrict(root, cwd string) ([]*TreeNode, error) {
874 dir := SessionsDir(root, cwd)
875 entries, err := os.ReadDir(dir)
876 if err != nil {
877 if errors.Is(err, os.ErrNotExist) {
878 return nil, nil
879 }
880 return nil, fmt.Errorf("build session tree: read directory: %w", err)
881 }
882 nodes := make(map[string]*TreeNode)
883 order := make([]string, 0, len(entries))
884 for _, e := range entries {
885 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
886 continue
887 }
888 path := filepath.Join(dir, e.Name())
889 snapshot, err := ReadSessionSnapshot(path)
890 if err != nil {
891 return nil, fmt.Errorf("build session tree: read %q: %w", path, err)
892 }
893 if snapshot.Meta.ID == "" {
894 return nil, fmt.Errorf("build session tree: %q has no session id", path)
895 }
896 summary := SessionSummary{
897 Path: path,
898 Started: snapshot.Meta.Started,
899 Model: snapshot.Meta.Model,
900 Provider: snapshot.Meta.Provider,
901 MessageCount: len(snapshot.Messages),
902 FirstUserText: firstUserTextFromMessages(snapshot.Messages),
903 Title: snapshot.Meta.Title,
904 }
905 if len(snapshot.UsageCheckpoints) > 0 {
906 summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
907 }
908 if _, exists := nodes[snapshot.Meta.ID]; exists {
909 return nil, fmt.Errorf("build session tree: duplicate session id %q", snapshot.Meta.ID)
910 }
911 nodes[snapshot.Meta.ID] = &TreeNode{Summary: summary, Meta: snapshot.Meta}
912 order = append(order, snapshot.Meta.ID)
913 }
914 return linkSessionTreeNodes(nodes, order), nil
915}
916
917// BuildSessionTreeFamilyStrict locates the current session using lightweight
918// metadata headers, then strictly validates only that root and its descendants.
919// A corrupt unrelated root in the same cwd bucket must not hide an otherwise
920// readable current family, while a corrupt member of the selected family still
921// fails the whole preflight.
922func BuildSessionTreeFamilyStrict(root, cwd, currentPath string) ([]*TreeNode, error) {
923 return BuildSessionTreeFamilyStrictContext(context.Background(), root, cwd, currentPath)
924}
925
926// BuildSessionTreeFamilyStrictContext is the cancellation-aware counterpart
927// to BuildSessionTreeFamilyStrict. It performs all filesystem reads on behalf
928// of callers that must remain responsive while a family is being discovered.
929func BuildSessionTreeFamilyStrictContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
930 roots, err := buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
931 if err != nil || len(roots) == 0 {
932 return roots, err
933 }
934 var validate func(*TreeNode) error
935 validate = func(node *TreeNode) error {
936 if err := contextErr(ctx); err != nil {
937 return err
938 }
939 snapshot, err := readSessionSnapshot(ctx, node.Summary.Path)
940 if err != nil {
941 return fmt.Errorf("build session family: read %q: %w", node.Summary.Path, err)
942 }
943 if snapshot.Meta.ID != node.Meta.ID {
944 return fmt.Errorf("build session family: session id changed in %q", node.Summary.Path)
945 }
946 node.Meta = snapshot.Meta
947 node.Summary = SessionSummary{
948 Path: node.Summary.Path,
949 Started: snapshot.Meta.Started,
950 Model: snapshot.Meta.Model,
951 Provider: snapshot.Meta.Provider,
952 MessageCount: len(snapshot.Messages),
953 FirstUserText: firstUserTextFromMessages(snapshot.Messages),
954 Title: snapshot.Meta.Title,
955 }
956 if len(snapshot.UsageCheckpoints) > 0 {
957 node.Summary.TotalCost = snapshot.UsageCheckpoints[len(snapshot.UsageCheckpoints)-1].Cumulative.CostUSD
958 }
959 for _, child := range node.Children {
960 if err := validate(child); err != nil {
961 return err
962 }
963 }
964 return nil
965 }
966 if err := validate(roots[0]); err != nil {
967 return nil, err
968 }
969 return roots, nil
970}
971
972// BuildSessionTreeFamilyContext discovers the current family from metadata
973// headers. Consumers that read every member themselves can start that work in
974// their preferred order without a redundant whole-family snapshot pass.
975func BuildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
976 return buildSessionTreeFamilyContext(ctx, root, cwd, currentPath)
977}
978
979func buildSessionTreeFamilyContext(ctx context.Context, root, cwd, currentPath string) ([]*TreeNode, error) {
980 if err := contextErr(ctx); err != nil {
981 return nil, err
982 }
983 if currentPath == "" {
984 return nil, errors.New("build session family: current path is empty")
985 }
986 dir := SessionsDir(root, cwd)
987 entries, err := os.ReadDir(dir)
988 if err != nil {
989 if errors.Is(err, os.ErrNotExist) {
990 return nil, nil
991 }
992 return nil, fmt.Errorf("build session family: read directory: %w", err)
993 }
994 cleanCurrent := filepath.Clean(currentPath)
995 nodes := make(map[string]*TreeNode)
996 order := make([]string, 0, len(entries))
997 for _, entry := range entries {
998 if err := contextErr(ctx); err != nil {
999 return nil, err
1000 }
1001 if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") {
1002 continue
1003 }
1004 path := filepath.Join(dir, entry.Name())
1005 meta, err := readSessionMetaHeader(path)
1006 if err != nil {
1007 if filepath.Clean(path) == cleanCurrent {
1008 return nil, fmt.Errorf("build session family: read current metadata %q: %w", path, err)
1009 }
1010 // A file whose header is unreadable cannot be associated with the
1011 // selected family. Valid descendants are linked below and fully
1012 // preflighted; unrelated corrupt roots stay out of this family.
1013 continue
1014 }
1015 if _, exists := nodes[meta.ID]; exists {
1016 return nil, fmt.Errorf("build session family: duplicate session id %q", meta.ID)
1017 }
1018 nodes[meta.ID] = &TreeNode{
1019 Summary: SessionSummary{Path: path, Started: meta.Started, Model: meta.Model, Provider: meta.Provider},
1020 Meta: meta,
1021 }
1022 order = append(order, meta.ID)
1023 }
1024 roots := linkSessionTreeNodes(nodes, order)
1025 var familyRoot *TreeNode
1026 for _, candidate := range roots {
1027 if treeNodeContainsPath(candidate, cleanCurrent) {
1028 familyRoot = candidate
1029 break
1030 }
1031 }
1032 if familyRoot == nil {
1033 return nil, fmt.Errorf("build session family: current path %q is not in the session forest", currentPath)
1034 }
1035 return []*TreeNode{familyRoot}, nil
1036}
1037
1038func readSessionMetaHeader(path string) (SessionMeta, error) {
1039 f, err := os.Open(path)
1040 if err != nil {
1041 return SessionMeta{}, err
1042 }
1043 defer f.Close()
1044 line, err := bufio.NewReader(f).ReadBytes('\n')
1045 if err != nil && len(line) == 0 {
1046 return SessionMeta{}, err
1047 }
1048 line = bytes.TrimSpace(line)
1049 var row sessionLine
1050 if err := json.Unmarshal(line, &row); err != nil {
1051 return SessionMeta{}, err
1052 }
1053 if row.Type != "meta" || row.Meta == nil || row.Meta.ID == "" {
1054 return SessionMeta{}, errors.New("first row is not a valid meta row")
1055 }
1056 return *row.Meta, nil
1057}
1058
1059func treeNodeContainsPath(node *TreeNode, path string) bool {
1060 if node == nil {
1061 return false
1062 }
1063 if filepath.Clean(node.Summary.Path) == filepath.Clean(path) {
1064 return true
1065 }
1066 for _, child := range node.Children {
1067 if treeNodeContainsPath(child, path) {
1068 return true
1069 }
1070 }
1071 return false
1072}
1073
1074func linkSessionTreeNodes(nodes map[string]*TreeNode, order []string) []*TreeNode {
1075 var roots []*TreeNode
1076 for _, id := range order {
1077 n := nodes[id]
1078 if n.Meta.Parent == "" {
1079 roots = append(roots, n)
1080 continue
1081 }
1082 if parent, ok := nodes[n.Meta.Parent]; ok {
1083 parent.Children = append(parent.Children, n)
1084 } else {
1085 // Parent file missing (was manually deleted). Treat as a root so it
1086 // still shows up in the tree.
1087 roots = append(roots, n)
1088 }
1089 }
1090 return roots
1091}
1092
1093// scanSessionMeta reads JSONL rows without hydrating transcript content and
1094// returns the latest valid metadata row. Listing and ID lookup only need this
1095// small projection; full snapshot validation remains the responsibility of
1096// resume, export, branching, and tree preflight callers.
1097func scanSessionMeta(path string) (meta SessionMeta, err error) {
1098 return scanSessionMetaContext(context.Background(), path)
1099}
1100
1101func scanSessionMetaContext(ctx context.Context, path string) (meta SessionMeta, err error) {
1102 if err := contextErr(ctx); err != nil {
1103 return SessionMeta{}, err
1104 }
1105 f, err := os.Open(path)
1106 if err != nil {
1107 return SessionMeta{}, err
1108 }
1109 defer f.Close()
1110
1111 var sawMeta bool
1112 err = forEachStrictJSONLLineContext(ctx, f, func(line []byte, lineNo int) error {
1113 var head sessionLineHead
1114 if err := json.Unmarshal(line, &head); err != nil {
1115 return fmt.Errorf("line %d: invalid JSON: %w", lineNo, err)
1116 }
1117 if head.Type == "" {
1118 return fmt.Errorf("line %d: missing row type", lineNo)
1119 }
1120 if !sawMeta && head.Type != "meta" {
1121 return fmt.Errorf("line %d: first row is not meta", lineNo)
1122 }
1123 switch head.Type {
1124 case "meta":
1125 var row struct {
1126 Meta *SessionMeta `json:"meta"`
1127 }
1128 if err := json.Unmarshal(line, &row); err != nil {
1129 return fmt.Errorf("line %d: invalid meta row: %w", lineNo, err)
1130 }
1131 if row.Meta == nil || row.Meta.ID == "" {
1132 return fmt.Errorf("line %d: meta row has no session id", lineNo)
1133 }
1134 meta = *row.Meta
1135 sawMeta = true
1136 case "message", "compaction", "usage", "rename", "extension_state":
1137 // These rows are intentionally not hydrated here.
1138 default:
1139 return fmt.Errorf("line %d: unknown row type %q", lineNo, head.Type)
1140 }
1141 return nil
1142 })
1143 if err != nil {
1144 return SessionMeta{}, fmt.Errorf("session metadata %q: %w", path, err)
1145 }
1146 if !sawMeta {
1147 return SessionMeta{}, fmt.Errorf("session metadata %q: file is empty", path)
1148 }
1149 return meta, nil
1150}
1151
1152// readSessionMeta returns the latest validated metadata for path without
1153// reconstructing its transcript. Full snapshot validation is performed by
1154// callers that need messages rather than listing metadata.
1155func readSessionMeta(path string) (SessionMeta, error) {
1156 return scanSessionMeta(path)
1157}
1158
1159// FindSessionByID looks up a session file in root/cwd whose meta id
1160// matches. Used by /session tree when the user picks an entry. O(n)
1161// over the files in the dir; the list is small in practice.
1162func FindSessionByID(root, cwd, id string) string {
1163 dir := SessionsDir(root, cwd)
1164 entries, err := os.ReadDir(dir)
1165 if err != nil {
1166 return ""
1167 }
1168 for _, e := range entries {
1169 if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
1170 continue
1171 }
1172 path := filepath.Join(dir, e.Name())
1173 meta, err := scanSessionMeta(path)
1174 if err != nil {
1175 continue
1176 }
1177 if meta.ID == id {
1178 return path
1179 }
1180 }
1181 return ""
1182}
1183
1184// FindManagedSessionByID looks up a session UUID across zut's managed
1185// session stores, independent of cwd. A nil error and empty path mean no
1186// matching session was found. Directory enumeration, cancellation, and
1187// duplicate-ID failures are returned to the caller. Candidate files that
1188// cannot be inspected or whose headers are unreadable or invalid are ignored
1189// because they cannot be associated with a requested UUID.
1190func FindManagedSessionByID(ctx context.Context, root, id string) (string, error) {
1191 if strings.TrimSpace(root) == "" {
1192 return "", errors.New("find managed session: root is empty")
1193 }
1194 if strings.TrimSpace(id) == "" {
1195 return "", errors.New("find managed session: id is empty")
1196 }
1197
1198 stores := []string{root}
1199 agentsDir := filepath.Join(root, "sessions", "agents")
1200 agents, err := os.ReadDir(agentsDir)
1201 if err != nil && !errors.Is(err, os.ErrNotExist) {
1202 return "", fmt.Errorf("find managed session: read agent stores: %w", err)
1203 }
1204 if err == nil {
1205 for _, entry := range agents {
1206 if entry.IsDir() {
1207 stores = append(stores, filepath.Join(agentsDir, entry.Name()))
1208 }
1209 }
1210 }
1211
1212 var matches []string
1213 for _, store := range stores {
1214 if err := contextErr(ctx); err != nil {
1215 return "", err
1216 }
1217 if err := collectManagedSessionMatches(ctx, store, id, &matches); err != nil {
1218 return "", err
1219 }
1220 }
1221 if err := contextErr(ctx); err != nil {
1222 return "", err
1223 }
1224 if len(matches) == 0 {
1225 return "", nil
1226 }
1227 if len(matches) > 1 {
1228 return "", fmt.Errorf("find managed session: UUID %q is ambiguous", id)
1229 }
1230 return matches[0], nil
1231}
1232
1233func collectManagedSessionMatches(ctx context.Context, root, id string, matches *[]string) error {
1234 if err := contextErr(ctx); err != nil {
1235 return err
1236 }
1237 dir := filepath.Join(root, "sessions")
1238 entries, err := os.ReadDir(dir)
1239 if errors.Is(err, os.ErrNotExist) {
1240 return nil
1241 }
1242 if err != nil {
1243 return fmt.Errorf("find managed session: read %q: %w", dir, err)
1244 }
1245 for _, bucket := range entries {
1246 if err := contextErr(ctx); err != nil {
1247 return err
1248 }
1249 if bucket.Type()&os.ModeSymlink != 0 || !bucket.IsDir() || bucket.Name() == "agents" || !isSessionBucketName(bucket.Name()) {
1250 continue
1251 }
1252 bucketPath := filepath.Join(dir, bucket.Name())
1253 files, err := os.ReadDir(bucketPath)
1254 if err != nil {
1255 return fmt.Errorf("find managed session: read %q: %w", bucketPath, err)
1256 }
1257 for _, file := range files {
1258 if err := contextErr(ctx); err != nil {
1259 return err
1260 }
1261 if file.Type()&os.ModeSymlink != 0 || file.IsDir() || !strings.HasSuffix(file.Name(), ".jsonl") {
1262 continue
1263 }
1264 path := filepath.Join(bucketPath, file.Name())
1265 info, err := os.Lstat(path)
1266 if err != nil || !info.Mode().IsRegular() {
1267 continue
1268 }
1269 meta, err := readSessionMetaHeader(path)
1270 if err != nil || meta.CWD == "" || filepath.Clean(SessionsDir(root, meta.CWD)) != filepath.Clean(bucketPath) {
1271 continue
1272 }
1273 if meta.ID == id {
1274 *matches = append(*matches, path)
1275 }
1276 }
1277 }
1278 return nil
1279}
1280
1281func isSessionBucketName(name string) bool {
1282 if len(name) != 16 {
1283 return false
1284 }
1285 for _, r := range name {
1286 if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
1287 return false
1288 }
1289 }
1290 return true
1291}
1292
1293// firstUserPrompt scans forward from the current source position
1294// looking for the first user-role message and returns its text.
1295// Used to build a humane export filename. Uses Reader instead of
1296// Scanner so a very large JSONL row before the first user prompt
1297// cannot trip Scanner's token limit.
1298func firstUserPrompt(src io.Reader) (string, error) {
1299 r := bufio.NewReader(src)
1300 for {
1301 lineBytes, err := r.ReadBytes('\n')
1302 if len(lineBytes) > 0 {
1303 lineBytes = bytes.TrimRight(lineBytes, "\r\n")
1304 var line sessionLine
1305 if err := json.Unmarshal(lineBytes, &line); err == nil {
1306 if line.Type == "message" && line.Message != nil && line.Message.Role == "user" {
1307 b, _ := json.Marshal(line.Message)
1308 var m struct {
1309 Content []struct {
1310 Text string `json:"text"`
1311 } `json:"content"`
1312 }
1313 _ = json.Unmarshal(b, &m)
1314 for _, c := range m.Content {
1315 if c.Text != "" {
1316 return c.Text, nil
1317 }
1318 }
1319 }
1320 }
1321 }
1322 if err == io.EOF {
1323 return "", nil
1324 }
1325 if err != nil {
1326 return "", err
1327 }
1328 }
1329}
1330
1331// filenameFor builds a descriptive .zutsession filename from the
1332// session's start time and, when available, an excerpt of the
1333// first user prompt.
1334func filenameFor(started time.Time, id, firstPrompt string) string {
1335 base := started.UTC().Format("20060102-150405")
1336 if id != "" && len(id) >= 8 {
1337 base += "-" + id[:8]
1338 }
1339 slug := slugify(firstPrompt, 40)
1340 if slug != "" {
1341 base += "-" + slug
1342 }
1343 return base + PortableExt
1344}
1345
1346// slugify lowercases, strips punctuation, collapses whitespace to
1347// hyphens, and truncates to max runes so it's safe as a filename.
1348func slugify(s string, max int) string {
1349 s = strings.TrimSpace(strings.ToLower(s))
1350 if s == "" {
1351 return ""
1352 }
1353 var out strings.Builder
1354 prevDash := false
1355 for _, r := range s {
1356 switch {
1357 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
1358 out.WriteRune(r)
1359 prevDash = false
1360 default:
1361 if !prevDash && out.Len() > 0 {
1362 out.WriteByte('-')
1363 prevDash = true
1364 }
1365 }
1366 if out.Len() >= max {
1367 break
1368 }
1369 }
1370 return strings.TrimRight(out.String(), "-")
1371}