import: add importers for cursor, pi, factory, codex, copilot, gemini · Entire

Home

Log in

import: add importers for cursor, pi, factory, codex, copilot, gemini

2045376→main·

computermode·3w ago·15 files·+1,679 added/-0 removed

Extends `entire import` beyond Claude Code to the other supported agents. PR #1527 built the agent-agnostic orchestration (discovery loop, idempotent per-turn IDs, redaction, checkpoint write) behind an Importer seam; this adds one self-contained Importer per agent and registers them, so each `entire import ` subcommand surfaces automatically.

Each importer reuses its agent's existing transcript methods rather than re-encoding parsing: - cursor: shared transcript.ExtractUserContent; no native token/model data - pi: CalculateTokenUsage + ExtractModel (branch-aware), _ filenames - factoryai-droid: subagent-aware CalculateTotalTokenUsage; model from the adjacent .settings.json - codex: walks the global sessions tree, filters by session_meta cwd, uses the cumulative-usage delta for per-turn tokens - copilot-cli: flat session-state store, filters by session.start cwd/gitRoot - gemini: single JSON document, imported per-session (message-index offsets)

Codex and Copilot share a repoMatches helper (equal-or-descendant, symlink normalized) so their global/flat stores only import this repo's sessions. Imported checkpoints keep the merged Claude behavior: commit-less, Kind "imported", not rewindable, idempotent.

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Sessions

b66e383bcad8View transcript

?\ Review Import Additions for Multiple AgentsCodex·GPT-5.5·1 step

Changes

15

65 unmodified lines

66
67
68
69
70
71
72
73
74
75
76
77

65 unmodified lines

// Importer implementation appended here — no init() / runtime registration.
var importers = []Importer{
    claudeImporter{},
    cursorImporter{},
    piImporter{},
    factoryImporter{},
    codexImporter{},
    copilotImporter{},
    geminiImporter{},
}

// Get returns the importer registered under name.

Mcmd/entire/cli/agentimport/agentimport.go+6

45 unmodified lines

46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
124 unmodified lines

206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257

45 unmodified lines

}
}

// TestRegistry_AllSupportedAgents asserts every supported importer is
// registered with a distinct name and a non-empty agent type.
func TestRegistry_AllSupportedAgents(t *testing.T) {
    t.Parallel()
    want := []string{
        "claude-code", "cursor", "pi", "factoryai-droid", "codex", "copilot-cli", "gemini",
    }
    for _, name := range want {
        imp, ok := Get(name)
        if !ok {
            t.Errorf("%s importer not registered", name)
            continue
        }
        if imp.AgentType() == "" {
            t.Errorf("%s importer has empty AgentType", name)
        }
    }

seen := make(map[string]bool)
    for _, imp := range All() {
        if seen[imp.Name()] {
            t.Errorf("duplicate importer name %q", imp.Name())
        }
        seen[imp.Name()] = true
    }
    if len(All()) != len(want) {
        t.Errorf("registered %d importers, want %d (%v)", len(All()), len(want), want)
    }
}

func initRepoWithCommit(t *testing.T) (*git.Repository, string) {
    t.Helper()
    repoDir := t.TempDir()
124 unmodified lines

}
}

// TestRun_CursorImporterEndToEnd exercises the generic Run pipeline through a
// non-Claude importer whose turns carry nil tokens and an empty model, proving
// the checkpoint write tolerates those (the riskiest divergence from Claude).
func TestRun_CursorImporterEndToEnd(t *testing.T) {
    t.Parallel()
    repo, repoDir := initRepoWithCommit(t)
    cursorDir := t.TempDir()
    content := strings.Join([]string{
        `{"role":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"hello"}}`,
        `{"role":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"hi"}]}}`,
    }, "\n") + "\n"
    if err := os.WriteFile(filepath.Join(cursorDir, "sessC.jsonl"), []byte(content), 0o644); err != nil {
        t.Fatal(err)
    }

opts := Options{RepoRoot: repoDir, OverridePath: cursorDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}
    res, err := Run(context.Background(), repo, cursorImporter{}, opts)
    if err != nil {
        t.Fatal(err)
    }
    if res.TurnsImported != 1 {
        t.Fatalf("want 1 imported, got %+v", res)
    }

// Re-run is idempotent.
    res2, err := Run(context.Background(), repo, cursorImporter{}, opts)
    if err != nil {
        t.Fatal(err)
    }
    if res2.TurnsImported != 0 || res2.TurnsSkipped != 1 {
        t.Fatalf("re-run not idempotent: %+v", res2)
    }

stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})
    if err != nil {
        t.Fatal(err)
    }
    infos, err := stores.Persistent.List(context.Background())
    if err != nil {
        t.Fatal(err)
    }
    if len(infos) != 1 || !infos[0].Imported {
        t.Fatalf("expected 1 imported cursor checkpoint, got %+v", infos)
    }
}

func TestRun_DryRunWritesNothing(t *testing.T) {
    t.Parallel()
    repo, repoDir := initRepoWithCommit(t)

Mcmd/entire/cli/agentimport/agentimport_test.go+76

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232

package agentimport

import (
    "bufio"
    "encoding/json"
    "errors"
    "fmt"
    "io/fs"
    "os"
    "path/filepath"
    "slices"
    "strconv"
    "strings"
    "time"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/agent/codex"
    "github.com/entireio/cli/cmd/entire/cli/agent/types"
)

// codexImporter imports Codex rollout transcripts. Codex stores sessions
// globally (CODEX_HOME/sessions/YYYY/MM/DD/rollout-*.jsonl), not per-repo, so
// Discover walks the tree and keeps only sessions whose session_meta cwd is the
// repo root or a descendant of it.
type codexImporter struct{}

func (codexImporter) Name() string { return string(agent.AgentNameCodex) }

func (codexImporter) AgentType() types.AgentType { return agent.AgentTypeCodex }

// codexSessionMeta is the subset of a Codex session_meta payload import needs.
type codexSessionMeta struct {
    ID  string `json:"id"`
    Cwd string `json:"cwd"`
}

// Discover walks the Codex sessions tree and returns transcripts belonging to
// this repo (by session_meta cwd) modified within the lookback window.
func (codexImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {
    dir := overridePath
    if dir == "" {
        ag := &codex.CodexAgent{}
        d, err := ag.GetSessionDir(repoRoot)
        if err != nil {
            return nil, fmt.Errorf("resolve codex session dir: %w", err)
        }
        dir = d
    }
    cutoff := now.AddDate(0, 0, -LookbackDays)
    var out []SessionFile
    walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
        if err != nil {
            if os.IsNotExist(err) {
                return nil // missing root or vanished entry: nothing to import
            }
            return err
        }
        if d.IsDir() || !strings.HasSuffix(d.Name(), ".jsonl") {
            return nil
        }
        info, statErr := d.Info()
        if statErr != nil || info.ModTime().Before(cutoff) {
            return nil //nolint:nilerr // skip unreadable/old entries, keep walking
        }
        meta, metaErr := codexReadSessionMeta(path)
        if metaErr != nil || !repoMatches(meta.Cwd, repoRoot) {
            return nil //nolint:nilerr // skip sessions we can't attribute to this repo
        }
        sessionID := meta.ID
        if sessionID == "" {
            sessionID = strings.TrimSuffix(d.Name(), ".jsonl")
        }
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, sessionID) {
            return nil
        }
        out = append(out, SessionFile{Path: path, SessionID: sessionID})
        return nil
    })
    if walkErr != nil {
        return nil, fmt.Errorf("walk codex sessions: %w", walkErr)
    }
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })
    return out, nil
}

// codexReadSessionMeta reads the first JSONL line of a rollout file and returns
// its session_meta payload. The first line must be session_meta by Codex's
// format.
func codexReadSessionMeta(path string) (codexSessionMeta, error) {
    f, err := os.Open(path) //nolint:gosec // path discovered by walking the configured session dir
    if err != nil {
        return codexSessionMeta{}, fmt.Errorf("open rollout: %w", err)
    }
    defer func() { _ = f.Close() }()
    r := bufio.NewReader(f)
    first, err := r.ReadBytes('\n')
    if len(first) == 0 && err != nil {
        return codexSessionMeta{}, fmt.Errorf("read session_meta line: %w", err)
    }
    var line struct {
        Type    string           `json:"type"`
        Payload codexSessionMeta `json:"payload"`
    }
    if jsonErr := json.Unmarshal(first, &line); jsonErr != nil || line.Type != "session_meta" {
        return codexSessionMeta{}, errors.New("first line is not session_meta")
    }
    return line.Payload, nil
}

// SplitTurns produces one Turn per user response_item, bounded by the next.
// Codex response_items carry no per-message UUID, so the turn's stable key is
// its (append-only) start line index. Token usage is delegated to the Codex
// agent, which computes the cumulative-usage delta for the line range.
func (codexImporter) SplitTurns(_ SessionFile, full []byte) ([]Turn, error) {
    rawLines := splitRawLines(full)
    var starts []int
    for i, raw := range rawLines {
        if _, ok := codexPromptText(raw); ok {
            starts = append(starts, i)
        }
    }

ag := &codex.CodexAgent{}
    turns := make([]Turn, 0, len(starts))
    for k, start := range starts {
        end := len(rawLines)
        if k+1 < len(starts) {
            end = starts[k+1]
        }

truncated := joinLines(rawLines[:end])
        tokens, err := ag.CalculateTokenUsage(truncated, start)
        if err != nil {
            return nil, fmt.Errorf("token usage for turn %d: %w", k, err)
        }

prompt, _ := codexPromptText(rawLines[start])
        ts := codexLineTime(rawLines[start])

turns = append(turns, Turn{
            LineStart: start,
            LineEnd:   end,
            UUID:      strconv.Itoa(start),
            Prompt:    prompt,
            CreatedAt: ts,
            Tokens:    tokens,
        })
    }
    return turns, nil
}

// codexPromptText reports whether a raw rollout line is a user-prompt
// response_item and returns its concatenated input_text. Assistant messages,
// tool calls, and event_msg lines return false.
func codexPromptText(raw []byte) (string, bool) {
    var line struct {
        Type    string          `json:"type"`
        Payload json.RawMessage `json:"payload"`
    }
    if err := json.Unmarshal(raw, &line); err != nil || line.Type != "response_item" {
        return "", false
    }
    var payload struct {
        Type    string `json:"type"`
        Role    string `json:"role"`
        Content []struct {
            Type string `json:"type"`
            Text string `json:"text"`
        } `json:"content"`
    }
    if err := json.Unmarshal(line.Payload, &payload); err != nil {
        return "", false
    }
    if payload.Type != "message" || payload.Role != "user" {
        return "", false
    }
    var texts []string
    for _, item := range payload.Content {
        if item.Type == "input_text" {
            if t := strings.TrimSpace(item.Text); t != "" {
                texts = append(texts, t)
            }
        }
    }
    if len(texts) == 0 {
        return "", false
    }
    return strings.Join(texts, "\n\n"), true
}

// codexLineTime returns the RFC3339 timestamp on a rollout line, or the zero
// time when absent or unparseable.
func codexLineTime(raw []byte) time.Time {
    var line struct {
        Timestamp string `json:"timestamp"`
    }
    if err := json.Unmarshal(raw, &line); err != nil {
        return time.Time{}
    }
    ts, err := time.Parse(time.RFC3339, line.Timestamp)
    if err != nil {
        return time.Time{}
    }
    return ts
}

// repoMatches reports whether cwd is the repo root or a descendant of it. Both
// paths are normalized (cleaned, symlinks resolved best-effort) before
// comparison. Used by the global/flat-store importers (Codex, Copilot) to keep
// only sessions belonging to this repo.
func repoMatches(cwd, repoRoot string) bool {
    if cwd == "" || repoRoot == "" {
        return false
    }
    rel, err := filepath.Rel(normalizePath(repoRoot), normalizePath(cwd))
    if err != nil {
        return false
    }
    return !strings.HasPrefix(rel, "..")
}

// normalizePath cleans a path and resolves symlinks when possible, so a cwd
// recorded through a symlinked path (e.g. macOS /var → /private/var) still
// matches the repo root. Falls back to the cleaned path when the target does
// not exist on this machine.
func normalizePath(p string) string {
    cleaned := filepath.Clean(p)
    if resolved, err := filepath.EvalSymlinks(cleaned); err == nil {
        return resolved
    }
    return cleaned
}

Acmd/entire/cli/agentimport/codex.go+232

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112

package agentimport

import (
    "os"
    "path/filepath"
    "strings"
    "testing"
    "time"
)

// codexSession builds a Codex rollout transcript whose session_meta carries the
// given id and cwd.
func codexSession(id, cwd string, body ...string) string {
    meta := `{"timestamp":"2026-06-20T00:00:00Z","type":"session_meta","payload":{"id":"` + id + `","cwd":"` + cwd + `"}}`
    return strings.Join(append([]string{meta}, body...), "\n") + "\n"
}

func TestCodexDiscover_RepoFilterLookbackRecursive(t *testing.T) {
    t.Parallel()
    dir := t.TempDir()
    repoRoot := "/work/myrepo"
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)

writeRollout := func(rel, id, cwd string, age time.Duration) {
        p := filepath.Join(dir, rel)
        if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
            t.Fatal(err)
        }
        if err := os.WriteFile(p, []byte(codexSession(id, cwd)), 0o644); err != nil {
            t.Fatal(err)
        }
        mt := now.Add(-age)
        if err := os.Chtimes(p, mt, mt); err != nil {
            t.Fatal(err)
        }
    }
    // In repo, recent (date-sharded path → exercises recursive walk).
    writeRollout("2026/06/20/rollout-a-mine.jsonl", "mine", repoRoot, 5*24*time.Hour)
    // In a subdir of the repo → still matches.
    writeRollout("2026/06/20/rollout-b-sub.jsonl", "sub", repoRoot+"/pkg", 5*24*time.Hour)
    // Different repo → excluded.
    writeRollout("2026/06/20/rollout-c-other.jsonl", "other", "/work/elsewhere", 5*24*time.Hour)
    // In repo but outside lookback → excluded.
    writeRollout("2026/05/01/rollout-d-old.jsonl", "old", repoRoot, 60*24*time.Hour)

got, err := codexImporter{}.Discover(repoRoot, dir, now, nil)
    if err != nil {
        t.Fatal(err)
    }
    gotIDs := map[string]bool{}
    for _, sf := range got {
        gotIDs[sf.SessionID] = true
    }
    if len(got) != 2 || !gotIDs["mine"] || !gotIDs["sub"] {
        t.Fatalf("repo/lookback filter wrong, got %v", gotIDs)
    }

got, err = codexImporter{}.Discover(repoRoot, dir, now, []string{"mine"})
    if err != nil {
        t.Fatal(err)
    }
    if len(got) != 1 || got[0].SessionID != "mine" {
        t.Fatalf("session filter wrong: %v", got)
    }
}

func TestCodexSplitTurns_PromptsAndTokenDelta(t *testing.T) {
    t.Parallel()
    full := []byte(codexSession("s1", "/work/myrepo",
        `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"first"}]}}`,
        `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5,"total_tokens":15}}}}`,
        `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"second"}]}}`,
        `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":30,"cached_input_tokens":0,"output_tokens":12,"total_tokens":42}}}}`,
    ))

turns, err := codexImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "r.jsonl"), SessionID: "s1"}, full)
    if err != nil {
        t.Fatal(err)
    }
    if len(turns) != 2 {
        t.Fatalf("want 2 turns, got %d", len(turns))
    }
    if turns[0].LineStart != 1 || turns[0].LineEnd != 3 {
        t.Errorf("turn0 bounds = [%d,%d), want [1,3)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != fxFirst || turns[1].Prompt != fxSecond {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    // Codex reports cumulative usage; the per-turn delta must be scoped.\
    if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
        t.Errorf("turn0 token delta wrong: %+v", turns[0].Tokens)\
    }\
    if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
        t.Errorf("turn1 token delta = %+v, want output 7 (12-5)", turns[1].Tokens)\
    }\
}\
\
func TestCodexSplitTurns_NonUserResponseItemIsNotATurn(t *testing.T) {\
    t.Parallel()\
    full := []byte(codexSession("s1", "/work/myrepo",\
        `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"do it"}]}}`,\
        `{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"working"}]}}`,\
        `{"type":"response_item","payload":{"type":"function_call","name":"shell","input":"ls"}}`,\
    ))\
    turns, err := codexImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "r.jsonl"), SessionID: "s1"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("only the user message starts a turn; want 1, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/codex\_test.go+112\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
124\
125\
126\
127\
128\
129\
130\
131\
132\
133\
134\
135\
136\
137\
138\
139\
140\
141\
142\
143\
144\
145\
146\
147\
148\
149\
150\
151\
152\
153\
154\
155\
156\
157\
158\
159\
160\
161\
162\
163\
164\
165\
166\
167\
\
package agentimport\
\
import (\
    "context"\
    "encoding/json"\
    "fmt"\
    "os"\
    "path/filepath"\
    "slices"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    "github.com/entireio/cli/cmd/entire/cli/agent/copilotcli"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
)\
\
// copilotImporter imports Copilot CLI transcripts. Copilot stores sessions\
// flat (~/.copilot/session-state/<id>/events.jsonl), not per-repo, so Discover\
// reads each session's session.start event and keeps only those whose\
// cwd/gitRoot is the repo root or a descendant.\
type copilotImporter struct{}\
\
func (copilotImporter) Name() string { return string(agent.AgentNameCopilotCLI) }\
\
func (copilotImporter) AgentType() types.AgentType { return agent.AgentTypeCopilotCLI }\
\
// Discover returns Copilot session transcripts belonging to this repo (by the\
// session.start context) modified within the lookback window. The session ID is\
// the session-state subdirectory name.\
func (copilotImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {\
    dir := overridePath\
    if dir == "" {\
        ag := &copilotcli.CopilotCLIAgent{}\
        d, err := ag.GetSessionDir(repoRoot)\
        if err != nil {\
            return nil, fmt.Errorf("resolve copilot session dir: %w", err)\
        }\
        dir = d\
    }\
    entries, err := os.ReadDir(dir)\
    if err != nil {\
        if os.IsNotExist(err) {\
            return nil, nil\
        }\
        return nil, fmt.Errorf("read copilot session dir: %w", err)\
    }\
    cutoff := now.AddDate(0, 0, -LookbackDays)\
    var out []SessionFile\
    for _, e := range entries {\
        if !e.IsDir() {\
            continue\
        }\
        sessionID := e.Name()\
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, sessionID) {\
            continue\
        }\
        path := filepath.Join(dir, sessionID, "events.jsonl")\
        info, statErr := os.Stat(path)\
        if statErr != nil || info.ModTime().Before(cutoff) {\
            continue\
        }\
        if !copilotSessionInRepo(path, repoRoot) {\
            continue\
        }\
        out = append(out, SessionFile{Path: path, SessionID: sessionID})\
    }\
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })\
    return out, nil\
}\
\
// copilotSessionInRepo reports whether the session's session.start event places\
// it in this repo (gitRoot or cwd is the repo root or a descendant). Sessions\
// whose location can't be determined are treated as not belonging to the repo.\
func copilotSessionInRepo(path, repoRoot string) bool {\
    data, err := os.ReadFile(path) //nolint:gosec // path discovered under the configured session dir\
    if err != nil {\
        return false\
    }\
    for _, raw := range splitRawLines(data) {\
        var evt struct {\
            Type string `json:"type"`\
            Data struct {\
                Context struct {\
                    Cwd     string `json:"cwd"`\
                    GitRoot string `json:"gitRoot"`\
                } `json:"context"`\
            } `json:"data"`\
        }\
        if err := json.Unmarshal(raw, &evt); err != nil || evt.Type != "session.start" {\
            continue\
        }\
        return repoMatches(evt.Data.Context.GitRoot, repoRoot) || repoMatches(evt.Data.Context.Cwd, repoRoot)\
    }\
    return false\
}\
\
// SplitTurns produces one Turn per user.message event, bounded by the next.\
// Token usage is delegated to the Copilot agent (per-turn slices sum\
// assistant.message outputTokens); the model is read once from the transcript.\
func (copilotImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {\
    rawLines := splitRawLines(full)\
    var starts []int\
    for i, raw := range rawLines {\
        if _, ok := copilotPromptText(raw); ok {\
            starts = append(starts, i)\
        }\
    }\
\
    ag := &copilotcli.CopilotCLIAgent{}\
    model := copilotcli.ExtractModelFromTranscript(context.Background(), sf.Path)\
    turns := make([]Turn, 0, len(starts))\
    for k, start := range starts {\
        end := len(rawLines)\
        if k+1 < len(starts) {\
            end = starts[k+1]\
        }\
\
        truncated := joinLines(rawLines[:end])\
        tokens, err := ag.CalculateTokenUsage(truncated, start)\
        if err != nil {\
            return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
        }\
\
        var evt struct {\
            ID        string `json:"id"`\
            Timestamp string `json:"timestamp"`\
        }\
        if err := json.Unmarshal(rawLines[start], &evt); err != nil {\
            continue\
        }\
        ts, parseErr := time.Parse(time.RFC3339, evt.Timestamp)\
        if parseErr != nil {\
            ts = time.Time{}\
        }\
        prompt, _ := copilotPromptText(rawLines[start])\
\
        turns = append(turns, Turn{\
            LineStart: start,\
            LineEnd:   end,\
            UUID:      evt.ID,\
            Prompt:    prompt,\
            Model:     model,\
            CreatedAt: ts,\
            Tokens:    tokens,\
        })\
    }\
    return turns, nil\
}\
\
// copilotPromptText reports whether a raw events.jsonl line is a user.message\
// and returns its content. Other event types return false.\
func copilotPromptText(raw []byte) (string, bool) {\
    var evt struct {\
        Type string `json:"type"`\
        Data struct {\
            Content string `json:"content"`\
        } `json:"data"`\
    }\
    if err := json.Unmarshal(raw, &evt); err != nil || evt.Type != "user.message" {\
        return "", false\
    }\
    if evt.Data.Content == "" {\
        return "", false\
    }\
    return evt.Data.Content, true\
}\
```\
\
Acmd/entire/cli/agentimport/copilot.go+167\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
\
package agentimport\
\
import (\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
    "time"\
)\
\
// copilotSession writes <base>/<id>/events.jsonl with a session.start carrying\
// gitRoot, and returns nothing (sets modtime via age).\
func writeCopilotSession(t *testing.T, base, id, gitRoot string, age time.Duration, now time.Time, body ...string) {\
    t.Helper()\
    sdir := filepath.Join(base, id)\
    if err := os.MkdirAll(sdir, 0o755); err != nil {\
        t.Fatal(err)\
    }\
    start := `{"type":"session.start","id":"s0","timestamp":"2026-06-20T00:00:00Z","data":{"context":{"cwd":"` + gitRoot + `","gitRoot":"` + gitRoot + `"}}}`\
    content := strings.Join(append([]string{start}, body...), "\n") + "\n"\
    p := filepath.Join(sdir, "events.jsonl")\
    if err := os.WriteFile(p, []byte(content), 0o644); err != nil {\
        t.Fatal(err)\
    }\
    mt := now.Add(-age)\
    if err := os.Chtimes(p, mt, mt); err != nil {\
        t.Fatal(err)\
    }\
}\
\
func TestCopilotDiscover_RepoFilterAndLookback(t *testing.T) {\
    t.Parallel()\
    base := t.TempDir()\
    repoRoot := "/work/myrepo"\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
\
    writeCopilotSession(t, base, "mine", repoRoot, 5*24*time.Hour, now)\
    writeCopilotSession(t, base, "other", "/work/elsewhere", 5*24*time.Hour, now)\
    writeCopilotSession(t, base, "old", repoRoot, 60*24*time.Hour, now)\
\
    got, err := copilotImporter{}.Discover(repoRoot, base, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "mine" {\
        t.Fatalf("repo/lookback filter wrong: %v", got)\
    }\
\
    got, err = copilotImporter{}.Discover(repoRoot, base, now, []string{"old"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 0 {\
        t.Fatalf("old session is outside lookback even when filtered: %v", got)\
    }\
}\
\
func TestCopilotSplitTurns_PromptsTokensModel(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    p := filepath.Join(dir, "events.jsonl")\
    full := []byte(strings.Join([]string{\
        `{"type":"session.start","id":"s0","timestamp":"2026-06-20T00:00:00Z","data":{"context":{"gitRoot":"/work/myrepo"}}}`,\
        `{"type":"session.model_change","id":"mc","data":{"newModel":"gpt-5"}}`,\
        `{"type":"user.message","id":"u1","timestamp":"2026-06-20T00:00:01Z","data":{"content":"first"}}`,\
        `{"type":"assistant.message","id":"a1","data":{"content":"ok","outputTokens":5}}`,\
        `{"type":"user.message","id":"u2","timestamp":"2026-06-20T00:01:00Z","data":{"content":"second"}}`,\
        `{"type":"assistant.message","id":"a2","data":{"content":"done","outputTokens":7}}`,\
    }, "\n") + "\n")\
    if err := os.WriteFile(p, full, 0o644); err != nil {\
        t.Fatal(err)\
    }\
\
    turns, err := copilotImporter{}.SplitTurns(SessionFile{Path: p, SessionID: "sess"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 2 {\
        t.Fatalf("want 2 turns, got %d", len(turns))\
    }\
    if turns[0].LineStart != 2 || turns[0].LineEnd != 4 {\
        t.Errorf("turn0 bounds = [%d,%d), want [2,4)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != fxFirst || turns[1].Prompt != fxSecond {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    if turns[0].UUID != "u1" {\
        t.Errorf("turn0 uuid = %q, want u1", turns[0].UUID)\
    }\
    if turns[0].Model != "gpt-5" {\
        t.Errorf("turn0 model = %q, want gpt-5", turns[0].Model)\
    }\
    if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
        t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
    }\
    if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
        t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
    }\
}\
\
func TestCopilotSplitTurns_NonUserEventIsNotATurn(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    p := filepath.Join(dir, "events.jsonl")\
    full := []byte(strings.Join([]string{\
        `{"type":"user.message","id":"u1","data":{"content":"do it"}}`,\
        `{"type":"tool.execution_complete","id":"t1","data":{"toolCallId":"x"}}`,\
        `{"type":"assistant.message","id":"a1","data":{"content":"done","outputTokens":3}}`,\
    }, "\n") + "\n")\
    if werr := os.WriteFile(p, full, 0o644); werr != nil {\
        t.Fatal(werr)\
    }\
    turns, err := copilotImporter{}.SplitTurns(SessionFile{Path: p, SessionID: "sess"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("only user.message starts a turn; want 1, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/copilot\_test.go+120\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
\
package agentimport\
\
import (\
    "encoding/json"\
    "fmt"\
    "os"\
    "path/filepath"\
    "slices"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    "github.com/entireio/cli/cmd/entire/cli/agent/cursor"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
    "github.com/entireio/cli/cmd/entire/cli/transcript"\
)\
\
// cursorImporter imports Cursor transcripts. Cursor uses the same JSONL line\
// format as Claude Code (role-tagged), so it reuses the shared user-prompt\
// detection and content extraction. Cursor records neither model nor token\
// usage, so imported turns carry an empty model and nil tokens.\
type cursorImporter struct{}\
\
func (cursorImporter) Name() string { return string(agent.AgentNameCursor) }\
\
func (cursorImporter) AgentType() types.AgentType { return agent.AgentTypeCursor }\
\
// Discover returns Cursor transcript files for the repo modified within the\
// lookback window. Cursor stores sessions either flat (<dir>/<id>.jsonl) or\
// nested (<dir>/<id>/<id>.jsonl, the IDE layout); both are discovered.\
func (cursorImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {\
    dir := overridePath\
    if dir == "" {\
        ag := &cursor.CursorAgent{}\
        d, err := ag.GetSessionDir(repoRoot)\
        if err != nil {\
            return nil, fmt.Errorf("resolve cursor session dir: %w", err)\
        }\
        dir = d\
    }\
    entries, err := os.ReadDir(dir)\
    if err != nil {\
        if os.IsNotExist(err) {\
            return nil, nil\
        }\
        return nil, fmt.Errorf("read cursor session dir: %w", err)\
    }\
    cutoff := now.AddDate(0, 0, -LookbackDays)\
    var out []SessionFile\
    for _, e := range entries {\
        stem, path := cursorSessionFile(dir, e)\
        if path == "" {\
            continue\
        }\
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {\
            continue\
        }\
        info, statErr := os.Stat(path)\
        if statErr != nil || info.ModTime().Before(cutoff) {\
            continue\
        }\
        out = append(out, SessionFile{Path: path, SessionID: stem})\
    }\
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })\
    return out, nil\
}\
\
// cursorSessionFile maps a directory entry to a (sessionID, transcript path),\
// resolving both the flat and nested Cursor layouts. Returns an empty path for\
// entries that are not Cursor transcripts.\
func cursorSessionFile(dir string, e os.DirEntry) (sessionID, path string) {\
    if e.IsDir() {\
        nested := filepath.Join(dir, e.Name(), e.Name()+".jsonl")\
        if _, err := os.Stat(nested); err == nil {\
            return e.Name(), nested\
        }\
        return "", ""\
    }\
    if !strings.HasSuffix(e.Name(), ".jsonl") {\
        return "", ""\
    }\
    return strings.TrimSuffix(e.Name(), ".jsonl"), filepath.Join(dir, e.Name())\
}\
\
// SplitTurns produces one Turn per user-prompt line, bounded by the next. It\
// reuses the package's shared JSONL helpers; Cursor carries no token usage or\
// model, so those fields are left zero.\
func (cursorImporter) SplitTurns(_ SessionFile, full []byte) ([]Turn, error) {\
    rawLines := splitRawLines(full)\
    var starts []int\
    for i, raw := range rawLines {\
        if isUserPromptLine(raw) {\
            starts = append(starts, i)\
        }\
    }\
    turns := make([]Turn, 0, len(starts))\
    for k, start := range starts {\
        end := len(rawLines)\
        if k+1 < len(starts) {\
            end = starts[k+1]\
        }\
        var rec struct {\
            UUID      string          `json:"uuid"`\
            Message   json.RawMessage `json:"message"`\
            Timestamp string          `json:"timestamp"`\
        }\
        if err := json.Unmarshal(rawLines[start], &rec); err != nil {\
            continue\
        }\
        ts, parseErr := time.Parse(time.RFC3339, rec.Timestamp)\
        if parseErr != nil {\
            ts = time.Time{}\
        }\
        turns = append(turns, Turn{\
            LineStart: start,\
            LineEnd:   end,\
            UUID:      rec.UUID,\
            Prompt:    transcript.ExtractUserContent(rec.Message),\
            CreatedAt: ts,\
        })\
    }\
    return turns, nil\
}\
```\
\
Acmd/entire/cli/agentimport/cursor.go+123\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
124\
125\
126\
127\
128\
129\
130\
131\
132\
133\
134\
135\
\
package agentimport\
\
import (\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
    "time"\
)\
\
func TestCursorDiscover_FlatLookbackAndFilter(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    writeAged := func(name string, age time.Duration) {\
        p := filepath.Join(dir, name)\
        if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
            t.Fatal(err)\
        }\
        mt := now.Add(-age)\
        if err := os.Chtimes(p, mt, mt); err != nil {\
            t.Fatal(err)\
        }\
    }\
    writeAged("recent.jsonl", 5*24*time.Hour)\
    writeAged("old.jsonl", 60*24*time.Hour)\
    writeAged("skip.txt", 1*time.Hour)\
\
    imp := cursorImporter{}\
    got, err := imp.Discover("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != fxRecent {\
        t.Fatalf("lookback filter wrong: %v", got)\
    }\
\
    writeAged("abc123.jsonl", 1*24*time.Hour)\
    got, err = imp.Discover("", dir, now, []string{"abc123"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "abc123" {\
        t.Fatalf("session filter wrong: %v", got)\
    }\
}\
\
// TestCursorDiscover_NestedLayout covers Cursor's IDE layout where the transcript\
// lives at <dir>/<id>/<id>.jsonl rather than flat at <dir>/<id>.jsonl.\
func TestCursorDiscover_NestedLayout(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    sessID := "nested-sess"\
    nestedDir := filepath.Join(dir, sessID)\
    if err := os.MkdirAll(nestedDir, 0o755); err != nil {\
        t.Fatal(err)\
    }\
    p := filepath.Join(nestedDir, sessID+".jsonl")\
    if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
        t.Fatal(err)\
    }\
    mt := now.Add(-2 * 24 * time.Hour)\
    if err := os.Chtimes(p, mt, mt); err != nil {\
        t.Fatal(err)\
    }\
\
    got, err := cursorImporter{}.Discover("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != sessID {\
        t.Fatalf("nested discover wrong: %v", got)\
    }\
    if got[0].Path != p {\
        t.Fatalf("nested path = %q, want %q", got[0].Path, p)\
    }\
}\
\
func TestCursorDiscover_MissingDirIsEmpty(t *testing.T) {\
    t.Parallel()\
    got, err := cursorImporter{}.Discover("", filepath.Join(t.TempDir(), "nope"), time.Now(), nil)\
    if err != nil {\
        t.Fatalf("missing dir should not error: %v", err)\
    }\
    if len(got) != 0 {\
        t.Fatalf("expected empty, got %v", got)\
    }\
}\
\
func TestCursorSplitTurns_TwoPromptsNoTokensNoModel(t *testing.T) {\
    t.Parallel()\
    // Cursor uses "role" (not "type") and records neither model nor token usage.\
    full := []byte(strings.Join([]string{\
        `{"role":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
        `{"role":"assistant","uuid":"a1","message":{"content":[{"type":"text","text":"ok"}]}}`,\
        `{"role":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
    }, "\n") + "\n")\
\
    turns, err := cursorImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 2 {\
        t.Fatalf("want 2 turns, got %d", len(turns))\
    }\
    if turns[0].LineStart != 0 || turns[0].LineEnd != 2 {\
        t.Errorf("turn0 bounds = [%d,%d), want [0,2)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != fxFirst || turns[1].Prompt != fxSecond {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    if turns[0].Tokens != nil {\
        t.Errorf("cursor records no tokens, want nil, got %+v", turns[0].Tokens)\
    }\
    if turns[0].Model != "" {\
        t.Errorf("cursor records no model, want empty, got %q", turns[0].Model)\
    }\
}\
\
func TestCursorSplitTurns_ToolResultIsNotATurn(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"role":"user","uuid":"u1","message":{"role":"user","content":"do it"}}`,\
        `{"role":"assistant","uuid":"a1","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}`,\
        `{"role":"user","uuid":"r1","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"out"}]}}`,\
    }, "\n") + "\n")\
    turns, err := cursorImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("tool_result must not start a turn; want 1 turn, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/cursor\_test.go+135\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
124\
125\
126\
127\
128\
129\
130\
131\
132\
133\
134\
135\
136\
137\
\
package agentimport\
\
import (\
    "encoding/json"\
    "fmt"\
    "os"\
    "path/filepath"\
    "slices"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    "github.com/entireio/cli/cmd/entire/cli/agent/factoryaidroid"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
    "github.com/entireio/cli/cmd/entire/cli/transcript"\
)\
\
// factoryImporter imports Factory AI Droid transcripts\
// (~/.factory/sessions/<repo>/<id>.jsonl). Droid wraps each message in an\
// envelope ({"type":"message","id":..,"message":{...}}); token usage is\
// subagent-aware and the model lives in an adjacent <id>.settings.json.\
type factoryImporter struct{}\
\
func (factoryImporter) Name() string { return string(agent.AgentNameFactoryAIDroid) }\
\
func (factoryImporter) AgentType() types.AgentType { return agent.AgentTypeFactoryAIDroid }\
\
// Discover returns Factory transcript files for the repo modified within the\
// lookback window.\
func (factoryImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {\
    dir := overridePath\
    if dir == "" {\
        ag := &factoryaidroid.FactoryAIDroidAgent{}\
        d, err := ag.GetSessionDir(repoRoot)\
        if err != nil {\
            return nil, fmt.Errorf("resolve factory session dir: %w", err)\
        }\
        dir = d\
    }\
    entries, err := os.ReadDir(dir)\
    if err != nil {\
        if os.IsNotExist(err) {\
            return nil, nil\
        }\
        return nil, fmt.Errorf("read factory session dir: %w", err)\
    }\
    cutoff := now.AddDate(0, 0, -LookbackDays)\
    var out []SessionFile\
    for _, e := range entries {\
        if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {\
            continue\
        }\
        stem := strings.TrimSuffix(e.Name(), ".jsonl")\
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {\
            continue\
        }\
        info, statErr := e.Info()\
        if statErr != nil || info.ModTime().Before(cutoff) {\
            continue\
        }\
        out = append(out, SessionFile{Path: filepath.Join(dir, e.Name()), SessionID: stem})\
    }\
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })\
    return out, nil\
}\
\
// SplitTurns produces one Turn per user-prompt envelope, bounded by the next.\
// Token usage (including spawned subagents) is delegated to the Factory agent;\
// the model is read once from the session's adjacent settings file.\
func (factoryImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {\
    rawLines := splitRawLines(full)\
    var starts []int\
    for i, raw := range rawLines {\
        if _, ok := factoryPromptText(raw); ok {\
            starts = append(starts, i)\
        }\
    }\
\
    subagentsDir := filepath.Join(filepath.Dir(sf.Path), sf.SessionID, "subagents")\
    model := factoryaidroid.ExtractModelFromTranscript(sf.Path)\
    ag := &factoryaidroid.FactoryAIDroidAgent{}\
    turns := make([]Turn, 0, len(starts))\
    for k, start := range starts {\
        end := len(rawLines)\
        if k+1 < len(starts) {\
            end = starts[k+1]\
        }\
\
        truncated := joinLines(rawLines[:end])\
        tokens, err := ag.CalculateTotalTokenUsage(truncated, start, subagentsDir)\
        if err != nil {\
            return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
        }\
\
        var env struct {\
            ID string `json:"id"`\
        }\
        if err := json.Unmarshal(rawLines[start], &env); err != nil {\
            continue\
        }\
        prompt, _ := factoryPromptText(rawLines[start])\
\
        turns = append(turns, Turn{\
            LineStart: start,\
            LineEnd:   end,\
            UUID:      env.ID,\
            Prompt:    prompt,\
            Model:     model,\
            Tokens:    tokens,\
        })\
    }\
    return turns, nil\
}\
\
// factoryPromptText reports whether a raw Droid JSONL line is a user-prompt\
// message and returns its text. Droid tags the role inside the inner message;\
// tool_result user messages carry no extractable text and return false.\
func factoryPromptText(raw []byte) (string, bool) {\
    var env struct {\
        Type    string          `json:"type"`\
        Message json.RawMessage `json:"message"`\
    }\
    if err := json.Unmarshal(raw, &env); err != nil || env.Type != "message" {\
        return "", false\
    }\
    var role struct {\
        Role string `json:"role"`\
    }\
    if err := json.Unmarshal(env.Message, &role); err != nil || role.Role != transcript.TypeUser {\
        return "", false\
    }\
    text := transcript.ExtractUserContent(env.Message)\
    if text == "" {\
        return "", false\
    }\
    return text, true\
}\
```\
\
Acmd/entire/cli/agentimport/factory.go+137\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
\
package agentimport\
\
import (\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
    "time"\
)\
\
func TestFactoryDiscover_LookbackAndFilter(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    writeAged := func(name string, age time.Duration) {\
        p := filepath.Join(dir, name)\
        if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
            t.Fatal(err)\
        }\
        mt := now.Add(-age)\
        if err := os.Chtimes(p, mt, mt); err != nil {\
            t.Fatal(err)\
        }\
    }\
    writeAged("recent.jsonl", 5*24*time.Hour)\
    writeAged("old.jsonl", 60*24*time.Hour)\
\
    got, err := factoryImporter{}.Discover("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != fxRecent {\
        t.Fatalf("lookback filter wrong: %v", got)\
    }\
\
    got, err = factoryImporter{}.Discover("", dir, now, []string{"old"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 0 {\
        t.Fatalf("old session is outside lookback even when filtered: %v", got)\
    }\
}\
\
func TestFactorySplitTurns_PromptsTokensSettingsModel(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    sessPath := filepath.Join(dir, "sess.jsonl")\
    // Droid envelope: {"type":"message","id":..,"message":{"role":..,"content":..}}.\
    full := []byte(strings.Join([]string{\
        `{"type":"session_start","id":"s0"}`,\
        `{"type":"message","id":"u1","message":{"role":"user","content":"first"}}`,\
        `{"type":"message","id":"a1","message":{"role":"assistant","id":"m1","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":10,"output_tokens":5}}}`,\
        `{"type":"message","id":"u2","message":{"role":"user","content":"second"}}`,\
        `{"type":"message","id":"a2","message":{"role":"assistant","id":"m2","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":20,"output_tokens":7}}}`,\
    }, "\n") + "\n")\
    if err := os.WriteFile(sessPath, full, 0o644); err != nil {\
        t.Fatal(err)\
    }\
    // Model comes from the adjacent <session>.settings.json, not the transcript.\
    if err := os.WriteFile(filepath.Join(dir, "sess.settings.json"), []byte(`{"model":"custom:Gemini-2.5-Pro-0"}`), 0o644); err != nil {\
        t.Fatal(err)\
    }\
\
    turns, err := factoryImporter{}.SplitTurns(SessionFile{Path: sessPath, SessionID: "sess"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 2 {\
        t.Fatalf("want 2 turns, got %d", len(turns))\
    }\
    if turns[0].LineStart != 1 || turns[0].LineEnd != 3 {\
        t.Errorf("turn0 bounds = [%d,%d), want [1,3)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != fxFirst || turns[1].Prompt != fxSecond {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    if turns[0].UUID != "u1" {\
        t.Errorf("turn0 uuid = %q, want u1", turns[0].UUID)\
    }\
    if turns[0].Model != "Gemini-2.5-Pro-0" {\
        t.Errorf("turn0 model = %q, want Gemini-2.5-Pro-0 (cleaned from settings)", turns[0].Model)\
    }\
    if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
        t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
    }\
    if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
        t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
    }\
}\
\
func TestFactorySplitTurns_ToolResultIsNotATurn(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"type":"message","id":"u1","message":{"role":"user","content":"do it"}}`,\
        `{"type":"message","id":"a1","message":{"role":"assistant","id":"m1","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}`,\
        `{"type":"message","id":"r1","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","content":"out"}]}}`,\
    }, "\n") + "\n")\
    turns, err := factoryImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("tool_result must not start a turn; want 1 turn, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/factory\_test.go+106\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
\
package agentimport\
\
// Shared fixture constants used across the per-agent importer tests. They keep\
// the common prompt/session literals in one place (and satisfy goconst).\
const (\
    fxFirst  = "first"\
    fxSecond = "second"\
    fxRecent = "recent"\
)\
```\
\
Acmd/entire/cli/agentimport/fixtures\_test.go+9\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
\
package agentimport\
\
import (\
    "fmt"\
    "os"\
    "path/filepath"\
    "slices"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    "github.com/entireio/cli/cmd/entire/cli/agent/geminicli"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
)\
\
// geminiImporter imports Gemini CLI transcripts\
// (~/.gemini/tmp/<project-hash>/chats/session-*.json). Unlike the JSONL agents,\
// a Gemini transcript is a single JSON document whose offsets are message\
// indices, not line numbers, so import is per-session: one checkpoint covering\
// the whole transcript rather than per-turn.\
type geminiImporter struct{}\
\
func (geminiImporter) Name() string { return string(agent.AgentNameGemini) }\
\
func (geminiImporter) AgentType() types.AgentType { return agent.AgentTypeGemini }\
\
// Discover returns Gemini transcript files for the repo modified within the\
// lookback window. The session ID is the file stem (session-<date>-<shortid>).\
func (geminiImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {\
    dir := overridePath\
    if dir == "" {\
        ag := &geminicli.GeminiCLIAgent{}\
        d, err := ag.GetSessionDir(repoRoot)\
        if err != nil {\
            return nil, fmt.Errorf("resolve gemini session dir: %w", err)\
        }\
        dir = d\
    }\
    entries, err := os.ReadDir(dir)\
    if err != nil {\
        if os.IsNotExist(err) {\
            return nil, nil\
        }\
        return nil, fmt.Errorf("read gemini session dir: %w", err)\
    }\
    cutoff := now.AddDate(0, 0, -LookbackDays)\
    var out []SessionFile\
    for _, e := range entries {\
        if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {\
            continue\
        }\
        stem := strings.TrimSuffix(e.Name(), ".json")\
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {\
            continue\
        }\
        info, statErr := e.Info()\
        if statErr != nil || info.ModTime().Before(cutoff) {\
            continue\
        }\
        out = append(out, SessionFile{Path: filepath.Join(dir, e.Name()), SessionID: stem})\
    }\
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })\
    return out, nil\
}\
\
// SplitTurns returns a single Turn covering the whole session. Offsets are\
// message indices (the native Gemini space): LineStart 0, LineEnd the message\
// count. Token usage is the whole-session total and the prompt is the first\
// user message. The turn UUID is the session ID so re-imports stay idempotent.\
func (geminiImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {\
    tr, err := geminicli.ParseTranscript(full)\
    if err != nil {\
        return nil, fmt.Errorf("parse gemini transcript: %w", err)\
    }\
    if len(tr.Messages) == 0 {\
        return nil, nil\
    }\
\
    ag := &geminicli.GeminiCLIAgent{}\
    tokens, err := ag.CalculateTokenUsage(full, 0)\
    if err != nil {\
        return nil, fmt.Errorf("token usage: %w", err)\
    }\
\
    prompt := ""\
    if prompts := geminicli.ExtractAllUserPromptsFromTranscript(tr); len(prompts) > 0 {\
        prompt = prompts[0]\
    }\
    // Gemini messages carry no per-message timestamp; the file modtime is the\
    // best available session time.\
    var createdAt time.Time\
    if info, statErr := os.Stat(sf.Path); statErr == nil {\
        createdAt = info.ModTime()\
    }\
\
    return []Turn{{\
        LineStart: 0,\
        LineEnd:   len(tr.Messages),\
        UUID:      sf.SessionID,\
        Prompt:    prompt,\
        CreatedAt: createdAt,\
        Tokens:    tokens,\
    }}, nil\
}\
```\
\
Acmd/entire/cli/agentimport/gemini.go+104\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
\
package agentimport\
\
import (\
    "os"\
    "path/filepath"\
    "testing"\
    "time"\
)\
\
func TestGeminiDiscover_LookbackAndFilter(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    writeAged := func(name string, age time.Duration) {\
        p := filepath.Join(dir, name)\
        if err := os.WriteFile(p, []byte(`{"messages":[]}`), 0o644); err != nil {\
            t.Fatal(err)\
        }\
        mt := now.Add(-age)\
        if err := os.Chtimes(p, mt, mt); err != nil {\
            t.Fatal(err)\
        }\
    }\
    writeAged("session-2026-06-20-recent01.json", 5*24*time.Hour)\
    writeAged("session-2026-04-01-old00001.json", 60*24*time.Hour)\
    writeAged("notes.txt", 1*time.Hour)\
\
    got, err := geminiImporter{}.Discover("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "session-2026-06-20-recent01" {\
        t.Fatalf("lookback/extension filter wrong: %v", got)\
    }\
\
    got, err = geminiImporter{}.Discover("", dir, now, []string{"session-2026-06-20-recent01"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 {\
        t.Fatalf("session filter wrong: %v", got)\
    }\
}\
\
// TestGeminiSplitTurns_OneCheckpointPerSession verifies Gemini imports at\
// session granularity: a single Turn covering the whole (message-indexed)\
// transcript, with whole-session tokens and the first user prompt.\
func TestGeminiSplitTurns_OneCheckpointPerSession(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    p := filepath.Join(dir, "session-x.json")\
    full := []byte(`{"messages":[` +\
        `{"type":"user","id":"u1","content":[{"text":"first"}]},` +\
        `{"type":"gemini","id":"g1","content":"ok","tokens":{"input":10,"output":5}},` +\
        `{"type":"user","id":"u2","content":[{"text":"second"}]},` +\
        `{"type":"gemini","id":"g2","content":"done","tokens":{"input":20,"output":7}}` +\
        `]}`)\
    if err := os.WriteFile(p, full, 0o644); err != nil {\
        t.Fatal(err)\
    }\
\
    turns, err := geminiImporter{}.SplitTurns(SessionFile{Path: p, SessionID: "session-x"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("gemini imports per-session; want 1 turn, got %d", len(turns))\
    }\
    turn := turns[0]\
    if turn.LineStart != 0 || turn.LineEnd != 4 {\
        t.Errorf("turn bounds = [%d,%d), want [0,4) in message-index space", turn.LineStart, turn.LineEnd)\
    }\
    if turn.UUID != "session-x" {\
        t.Errorf("per-session turn uuid = %q, want session-x (stable/idempotent)", turn.UUID)\
    }\
    if turn.Prompt != "first" {\
        t.Errorf("prompt = %q, want the first user prompt", turn.Prompt)\
    }\
    // Whole-session totals: 5 + 7 output, 10 + 20 input.\
    if turn.Tokens == nil || turn.Tokens.OutputTokens != 12 || turn.Tokens.InputTokens != 30 {\
        t.Errorf("session tokens wrong: %+v", turn.Tokens)\
    }\
}\
\
func TestGeminiSplitTurns_EmptyTranscriptNoTurns(t *testing.T) {\
    t.Parallel()\
    turns, err := geminiImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.json"), SessionID: "s"}, []byte(`{"messages":[]}`))\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 0 {\
        t.Fatalf("empty transcript should yield no turns, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/gemini\_test.go+94\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
99\
100\
101\
102\
103\
104\
105\
106\
107\
108\
109\
110\
111\
112\
113\
114\
115\
116\
117\
118\
119\
120\
121\
122\
123\
124\
125\
126\
127\
128\
129\
130\
131\
132\
133\
134\
135\
136\
137\
138\
139\
140\
141\
142\
143\
144\
145\
146\
147\
148\
149\
150\
151\
152\
153\
154\
155\
156\
157\
158\
159\
160\
\
package agentimport\
\
import (\
    "encoding/json"\
    "fmt"\
    "os"\
    "path/filepath"\
    "slices"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    "github.com/entireio/cli/cmd/entire/cli/agent/pi"\
    "github.com/entireio/cli/cmd/entire/cli/agent/pi/pijsonl"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
)\
\
// piImporter imports Pi transcripts (~/.pi/agent/sessions/<repo>/<ts>_<uuid>.jsonl).\
// Pi records token usage and the model on every assistant message, so imported\
// turns carry both via the agent's own CalculateTokenUsage / ExtractModel.\
type piImporter struct{}\
\
func (piImporter) Name() string { return string(agent.AgentNamePi) }\
\
func (piImporter) AgentType() types.AgentType { return agent.AgentTypePi }\
\
// Discover returns Pi transcript files for the repo modified within the lookback\
// window. The session ID is the <uuid> suffix of the <timestamp>_<uuid> file\
// stem (Pi timestamps use dashes, so the first underscore is the separator).\
func (piImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {\
    dir := overridePath\
    if dir == "" {\
        ag := &pi.PiAgent{}\
        d, err := ag.GetSessionDir(repoRoot)\
        if err != nil {\
            return nil, fmt.Errorf("resolve pi session dir: %w", err)\
        }\
        dir = d\
    }\
    entries, err := os.ReadDir(dir)\
    if err != nil {\
        if os.IsNotExist(err) {\
            return nil, nil\
        }\
        return nil, fmt.Errorf("read pi session dir: %w", err)\
    }\
    cutoff := now.AddDate(0, 0, -LookbackDays)\
    var out []SessionFile\
    for _, e := range entries {\
        if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {\
            continue\
        }\
        stem := strings.TrimSuffix(e.Name(), ".jsonl")\
        sessionID := piSessionID(stem)\
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, sessionID) {\
            continue\
        }\
        info, statErr := e.Info()\
        if statErr != nil || info.ModTime().Before(cutoff) {\
            continue\
        }\
        out = append(out, SessionFile{Path: filepath.Join(dir, e.Name()), SessionID: sessionID})\
    }\
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })\
    return out, nil\
}\
\
// piSessionID extracts the <uuid> portion of a "<timestamp>_<uuid>" file stem.\
// Falls back to the whole stem when there is no underscore separator.\
func piSessionID(stem string) string {\
    if i := strings.Index(stem, "_"); i >= 0 {\
        return stem[i+1:]\
    }\
    return stem\
}\
\
// SplitTurns produces one Turn per user-prompt message line, bounded by the\
// next. Token usage and model are delegated to the Pi agent so import reuses the\
// same accounting (branch-aware) the live path uses.\
func (piImporter) SplitTurns(_ SessionFile, full []byte) ([]Turn, error) {\
    rawLines := splitRawLines(full)\
    var starts []int\
    for i, raw := range rawLines {\
        if _, ok := piPromptText(raw); ok {\
            starts = append(starts, i)\
        }\
    }\
\
    ag := &pi.PiAgent{}\
    turns := make([]Turn, 0, len(starts))\
    for k, start := range starts {\
        end := len(rawLines)\
        if k+1 < len(starts) {\
            end = starts[k+1]\
        }\
\
        // Bound token usage to [start, end): truncate to the first `end` lines,\
        // then let the agent helper slice from `start`.\
        truncated := joinLines(rawLines[:end])\
        tokens, err := ag.CalculateTokenUsage(truncated, start)\
        if err != nil {\
            return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
        }\
        model, mErr := ag.ExtractModel(joinLines(rawLines[start:end]))\
        if mErr != nil {\
            model = ""\
        }\
\
        var entry pijsonl.Entry\
        if err := json.Unmarshal(rawLines[start], &entry); err != nil {\
            continue\
        }\
        ts, parseErr := time.Parse(time.RFC3339, entry.Timestamp)\
        if parseErr != nil {\
            ts = time.Time{}\
        }\
        prompt, _ := piPromptText(rawLines[start])\
\
        turns = append(turns, Turn{\
            LineStart: start,\
            LineEnd:   end,\
            UUID:      entry.ID,\
            Prompt:    prompt,\
            Model:     model,\
            CreatedAt: ts,\
            Tokens:    tokens,\
        })\
    }\
    return turns, nil\
}\
\
// piPromptText reports whether a raw Pi JSONL line is a user-prompt message and\
// returns its text. Pi user content may be a plain string or an array of typed\
// blocks; toolResult/assistant messages and empty content return false.\
func piPromptText(raw []byte) (string, bool) {\
    var entry pijsonl.Entry\
    if err := json.Unmarshal(raw, &entry); err != nil {\
        return "", false\
    }\
    if entry.Type != pijsonl.EntryTypeMessage || entry.Message.Role != pijsonl.RoleUser {\
        return "", false\
    }\
    if s := pijsonl.DecodeStringContent(entry.Message.Content); s != "" {\
        return s, true\
    }\
    var items []pijsonl.ContentItem\
    if err := json.Unmarshal(entry.Message.Content, &items); err != nil {\
        return "", false\
    }\
    var texts []string\
    for _, it := range items {\
        if it.Type == pijsonl.ContentTypeText && it.Text != "" {\
            texts = append(texts, it.Text)\
        }\
    }\
    if len(texts) == 0 {\
        return "", false\
    }\
    return strings.Join(texts, "\n\n"), true\
}\
```\
\
Acmd/entire/cli/agentimport/pi.go+160\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
21\
22\
23\
24\
25\
26\
27\
28\
29\
30\
31\
32\
33\
34\
35\
36\
37\
38\
39\
40\
41\
42\
43\
44\
45\
46\
47\
48\
49\
50\
51\
52\
53\
54\
55\
56\
57\
58\
59\
60\
61\
62\
63\
64\
65\
66\
67\
68\
69\
70\
71\
72\
73\
74\
75\
76\
77\
78\
79\
80\
81\
82\
83\
84\
85\
86\
87\
88\
89\
90\
91\
92\
93\
94\
95\
96\
97\
98\
\
package agentimport\
\
import (\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
    "time"\
)\
\
func TestPiDiscover_LookbackFilterAndSessionID(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    writeAged := func(name string, age time.Duration) {\
        p := filepath.Join(dir, name)\
        if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
            t.Fatal(err)\
        }\
        mt := now.Add(-age)\
        if err := os.Chtimes(p, mt, mt); err != nil {\
            t.Fatal(err)\
        }\
    }\
    // Pi names files <timestamp>_<uuid>.jsonl; the session ID is the uuid suffix.\
    writeAged("2026-06-20T00-00-00-000Z_sessA.jsonl", 5*24*time.Hour)\
    writeAged("2026-04-01T00-00-00-000Z_old.jsonl", 60*24*time.Hour)\
\
    got, err := piImporter{}.Discover("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "sessA" {\
        t.Fatalf("lookback/session-id wrong: %v", got)\
    }\
\
    got, err = piImporter{}.Discover("", dir, now, []string{"sessA"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "sessA" {\
        t.Fatalf("session filter wrong: %v", got)\
    }\
}\
\
func TestPiSplitTurns_PromptsTokensModel(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"type":"session","id":"s0","timestamp":"2026-06-20T00:00:00Z"}`,\
        `{"type":"message","id":"u1","timestamp":"2026-06-20T00:00:01Z","message":{"role":"user","content":"first"}}`,\
        `{"type":"message","id":"a1","timestamp":"2026-06-20T00:00:02Z","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"model":"gpt-5.5","usage":{"input":10,"output":5}}}`,\
        `{"type":"message","id":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
        `{"type":"message","id":"a2","timestamp":"2026-06-20T00:01:02Z","message":{"role":"assistant","content":[{"type":"text","text":"done"}],"model":"gpt-5.5","usage":{"input":20,"output":7}}}`,\
    }, "\n") + "\n")\
\
    turns, err := piImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 2 {\
        t.Fatalf("want 2 turns, got %d", len(turns))\
    }\
    if turns[0].LineStart != 1 || turns[0].LineEnd != 3 {\
        t.Errorf("turn0 bounds = [%d,%d), want [1,3)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != fxFirst || turns[1].Prompt != fxSecond {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    if turns[0].UUID != "u1" {\
        t.Errorf("turn0 uuid = %q, want u1", turns[0].UUID)\
    }\
    if turns[0].Model != "gpt-5.5" {\
        t.Errorf("turn0 model = %q, want gpt-5.5", turns[0].Model)\
    }\
    if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
        t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
    }\
    if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
        t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
    }\
}\
\
func TestPiSplitTurns_ToolResultIsNotATurn(t *testing.T) {\
    t.Parallel()\
    // A toolResult-role message is not a user prompt and must not start a turn.\
    full := []byte(strings.Join([]string{\
        `{"type":"message","id":"u1","message":{"role":"user","content":"do it"}}`,\
        `{"type":"message","id":"a1","message":{"role":"assistant","content":[{"type":"toolCall","name":"edit","id":"t1","arguments":{}}]}}`,\
        `{"type":"message","id":"r1","message":{"role":"toolResult","content":"out","toolCallId":"t1"}}`,\
    }, "\n") + "\n")\
    turns, err := piImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("non-user message must not start a turn; want 1, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/pi\_test.go+98