Merge pull request #1589 from entireio/feat-image-externalize · Entire

Home

Log in

Merge pull request #1589 from entireio/feat-image-externalize

7cd6662→main·

suhaanthayyil·1w ago·23 files·+3,323 added/-37 removed

feat(transcript): externalize images (Claude Code, Codex) + capture Cursor sidecar images

Changes

23

10 unmodified lines

11
12
13
14
15
16
17
18
19
20
21
22
23
24
16 unmodified lines

41
42
43
44
45
46
47
48
49
50
51
143 unmodified lines

195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
234 unmodified lines

449
450
451
452
453
454
455
456
457

10 unmodified lines

"github.com/go-git/go-git/v6/plumbing"
)

// TranscriptAsset is a binary blob (e.g. an image) lifted out of a transcript
// and stored raw in the checkpoint, referenced by a placeholder in the log.
type TranscriptAsset struct {
    Name      string // stable asset filename / id, also used in the placeholder
    MediaType string
    Data      []byte
}

// WriteOptions contains options for writing a persistent checkpoint.
type WriteOptions struct {
    // CheckpointID is the stable 12-hex-char identifier
16 unmodified lines

// Must be pre-redacted (via redact.JSONLBytes or redact.AlreadyRedacted for trusted sources).
    Transcript redact.RedactedBytes

// Assets are binary blobs (e.g. images) lifted out of Transcript and
    // referenced by path-bearing placeholders. Stored raw under the session's
    // assets/ folder. Empty for agents/transcripts with no externalized images.
    Assets []TranscriptAsset

// Prompts contains the raw user prompts from the session. Run through
    // redactedJoinedPrompts before persisting — the writer does this
    // inside writeSessionToSubdirectory.
143 unmodified lines

// Must be pre-redacted (via redact.JSONLBytes or redact.AlreadyRedacted for trusted sources).
    Transcript redact.RedactedBytes

// Assets are the externalized image blobs matching Transcript's placeholders
    // (see WriteOptions.Assets). Set together with Transcript so the backfill keeps
    // the stored assets/ folder consistent with the transcript; empty clears any
    // previously-stored assets when Transcript is replaced.
    Assets []TranscriptAsset

// PreserveAssetsWhenEmpty keeps already-stored assets instead of clearing them
    // when Assets is empty. Set on the finalize path for agents whose assets come
    // from a best-effort sidecar capture (e.g. Cursor's sqlite3 store read): a
    // transient capture miss at finalize must not wipe images a prior condensation
    // successfully stored. Left false for codec agents, where an empty set means
    // "the transcript has no images" and stale asset blobs should be cleared.
    PreserveAssetsWhenEmpty bool

// Prompts contains the raw user prompts (replaces existing).
    // See WriteOptions.Prompts.
    Prompts []string
234 unmodified lines

CompactTranscript string `json:"compact_transcript,omitempty"`
    ContentHash       string `json:"content_hash,omitempty"`
    Prompt            string `json:"prompt"`
    // AssetsManifest points at assets/manifest.json when images were externalized
    // out of the transcript into the session's assets/ folder. Omitted otherwise.
    AssetsManifest string `json:"assets_manifest,omitempty"`
}

// CheckpointSummary is the root-level metadata.json for a checkpoint.

Mapi/checkpoint/metadata.go+30

187 unmodified lines

188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207

187 unmodified lines

PrepareTranscript(ctx context.Context, sessionRef string) error
}

// SidecarImageProvider is implemented by agents that keep images OUTSIDE the
// transcript Entire condenses — e.g. Cursor stores pasted images in a per-session
// SQLite blob store, not the JSONL transcript. The strategy layer calls this
// during condensation/finalize to capture those images as checkpoint assets so
// they're preserved with the session. Best-effort: returns nil (no error) when
// the sidecar store is unavailable or unreadable.
type SidecarImageProvider interface {
    Agent

// SidecarImages returns images stored outside the transcript for the session
    // identified by sessionRef (the transcript path).
    SidecarImages(ctx context.Context, sessionRef string) ([]CompactedTranscriptAsset, error)
}

// TokenCalculator provides token usage calculation for a session.
// The framework calls this during step save and checkpoint if implemented.
type TokenCalculator interface {

Mcmd/entire/cli/agent/agent.go+14

75 unmodified lines

76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93

75 unmodified lines

return declaredCapability[TranscriptPreparer](ag, func(c DeclaredCaps) bool { return c.TranscriptPreparer })
}

// AsSidecarImageProvider returns the agent as SidecarImageProvider if it
// implements the interface. This is a best-effort, optional capability (image
// capture from a store outside the transcript, e.g. Cursor's SQLite blob store),
// so it resolves by type assertion alone with no DeclaredCaps gate.
func AsSidecarImageProvider(ag Agent) (SidecarImageProvider, bool) {
    if ag == nil {
        return nil, false
    }
    p, ok := ag.(SidecarImageProvider)
    return p, ok
}

// AsTokenCalculator returns the agent as TokenCalculator if it both
// implements the interface and (for CapabilityDeclarer agents) has declared the capability.
func AsTokenCalculator(ag Agent) (TokenCalculator, bool) {

Mcmd/entire/cli/agent/capabilities.go+12

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
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
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295

package cursor

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "errors"
    "fmt"
    "io"
    "log/slog"
    "os"
    "os/exec"
    "path/filepath"
    "strings"
    "time"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/logging"
)

// Compile-time interface assertion.
var _ agent.SidecarImageProvider = (*CursorAgent)(nil)

// cursorChatsDirEnv overrides the base directory that holds Cursor's per-session
// SQLite blob stores. Used by tests and mock environments.
const cursorChatsDirEnv = "ENTIRE_TEST_CURSOR_CHATS_DIR"

const (
    // maxStoreDBBytes bounds the work: a store.db larger than this is skipped
    // (best-effort no-op). sqlite3's hex() output is ~2x the blob size and is
    // buffered in memory, so this caps peak memory. A normal Cursor store holding
    // screenshots is well under this.
    maxStoreDBBytes = 64 << 20 // 64MB

// sqlite3Timeout bounds the sidecar read so a locked, huge, or malformed
    // store.db can never hang the git commit / stop hook it runs inside.
    sqlite3Timeout = 30 * time.Second
)

// storeDBBlobQuery selects the hex encoding of every blob whose leading bytes
// match a known image magic number (JPEG, PNG, GIF, or RIFF/WEBP). sqlite3's
// hex() returns uppercase, so the literals are uppercase.
const storeDBBlobQuery = "SELECT hex(data) FROM blobs WHERE " +
    "substr(hex(data),1,6)='FFD8FF' OR " + // JPEG
    "substr(hex(data),1,8)='89504E47' OR " + // PNG
    "substr(hex(data),1,8)='47494638' OR " + // GIF
    "(substr(hex(data),1,8)='52494646' AND substr(hex(data),17,8)='57454250');" // RIFF....WEBP

// SidecarImages captures images that Cursor stores outside the JSONL transcript.
// Cursor keeps pasted/generated images in a per-session SQLite blob store
// (~/.cursor/chats/<workspace>/<session>/store.db), not the transcript Entire
// condenses, so they would otherwise be lost from the checkpoint. This locates
// that store for the session, shells out to the sqlite3 binary to read the image
// blobs, and returns them as checkpoint assets.
//
// It is best-effort: when the store, the sqlite3 binary, or the expected schema
// is absent, or the store is too large, it returns no images and no error.
// sessionRef is the transcript path.
func (c *CursorAgent) SidecarImages(ctx context.Context, sessionRef string) ([]agent.CompactedTranscriptAsset, error) {
    logCtx := logging.WithComponent(ctx, "agent.cursor")

sessionID := sessionIDFromTranscriptPath(sessionRef)
    if sessionID == "" {
        return nil, nil
    }

dbPaths, err := findStoreDBs(sessionID)
    if err != nil {
        return nil, fmt.Errorf("locate cursor store.db: %w", err)
    }
    if len(dbPaths) == 0 {
        return nil, nil // no sidecar store for this session
    }

if !sqlite3Available() {
        logging.Debug(logCtx, "sqlite3 not found; skipping cursor sidecar image capture")
        return nil, nil
    }

assets := make([]agent.CompactedTranscriptAsset, 0)
    seen := make(map[string]struct{})
    for _, dbPath := range dbPaths {
        hexBlobs, err := readImageBlobs(logCtx, dbPath)
        if err != nil {
            return nil, fmt.Errorf("read cursor store.db blobs: %w", err)
        }
        for _, h := range hexBlobs {
            data, err := hex.DecodeString(h)
            if err != nil {
                logging.Debug(logCtx, "skipping undecodable cursor blob", slog.String("error", err.Error()))
                continue
            }
            if len(data) > agent.MaxChunkSize {
                // A blob this large would become an unpushable git object; drop it.
                logging.Debug(logCtx, "skipping oversized cursor image", slog.Int("bytes", len(data)))
                continue
            }
            mediaType, ext := detectImageType(data)
            if mediaType == "" {
                continue // not an image after all
            }
            sum := sha256.Sum256(data)
            name := fmt.Sprintf("img-%s.%s", hex.EncodeToString(sum[:16]), ext)
            if _, dup := seen[name]; dup {
                continue // identical image already captured
            }
            seen[name] = struct{}{}
            assets = append(assets, agent.CompactedTranscriptAsset{
                Name:      name,
                MediaType: mediaType,
                Data:      data,
            })
        }
    }

if len(assets) > 0 {
        logging.Debug(logCtx, "captured cursor sidecar images",
            slog.Int("count", len(assets)), slog.String("session", sessionID))
    }
    return assets, nil
}

// sessionIDFromTranscriptPath extracts the Cursor session id from a transcript
// path. Both the nested (<id>/<id>.jsonl) and flat (<id>.jsonl) layouts name the
// file after the session id, so the base name without extension is the id.
// Returns "" for a path whose base resolves to "." or ".." (never a real id).
func sessionIDFromTranscriptPath(transcriptPath string) string {
    if transcriptPath == "" {
        return ""
    }
    base := filepath.Base(transcriptPath)
    id := strings.TrimSuffix(base, filepath.Ext(base))
    if id == "." || id == ".." {
        return ""
    }
    return id
}

// findStoreDBs locates every SQLite blob store for a session. Cursor lays these
// out as <chats>/<workspace-hash>/<session-id>/store.db; the workspace hash is
// not derivable from the session id, so we enumerate workspaces and check each.
//
// Only the workspace level is globbed; the session id is joined as a LITERAL
// path component (checked with os.Stat), so glob metacharacters in the id can't
// widen the match to a different session's store. Returns all matches (a session
// id is a UUID, so normally exactly one) — callers union + dedup the images,
// which avoids silently dropping images when a session resolves under more than
// one workspace directory.
func findStoreDBs(sessionID string) ([]string, error) {
    base := os.Getenv(cursorChatsDirEnv)
    if base == "" {
        home, err := os.UserHomeDir()
        if err != nil {
            return nil, fmt.Errorf("get home directory: %w", err)
        }
        base = filepath.Join(home, ".cursor", "chats")
    }

workspaces, err := filepath.Glob(filepath.Join(base, "*"))
    if err != nil {
        return nil, fmt.Errorf("glob cursor workspaces: %w", err)
    }
    var dbs []string
    for _, ws := range workspaces {
        p := filepath.Join(ws, sessionID, "store.db")
        if fileExists(p) {
            dbs = append(dbs, p)
        }
    }
    return dbs, nil
}

// readImageBlobs copies the store to a temp location (so a live Cursor session
// cannot lock or mutate it mid-read, and any WAL is applied) and shells out to
// sqlite3 to select image blobs as hex. Returns one hex string per image blob.
//
// Best-effort: a store larger than maxStoreDBBytes, or one whose schema is not
// the expected blobs(data) shape, returns (nil, nil) — an expected miss, not an
// error, so it never spams a warning on every checkpoint.
func readImageBlobs(ctx context.Context, dbPath string) ([]string, error) {
    if info, err := os.Stat(dbPath); err == nil && info.Size() > maxStoreDBBytes {
        logging.Debug(ctx, "cursor store.db too large; skipping sidecar capture",
            slog.Int64("bytes", info.Size()))
        return nil, nil
    }

tmpDir, err := os.MkdirTemp("", "entire-cursor-store-")
    if err != nil {
        return nil, fmt.Errorf("create temp dir: %w", err)
    }
    defer func() { _ = os.RemoveAll(tmpDir) }()

tmpDB := filepath.Join(tmpDir, "store.db")
    if err := copyFile(dbPath, tmpDB); err != nil {
        return nil, fmt.Errorf("copy store.db: %w", err)
    }
    // Copy the WAL/SHM sidecars if present so committed-but-not-checkpointed
    // pages are applied when sqlite3 opens the copy. Best-effort: a missing or
    // uncopyable sidecar just means we read the main db as-is.
    for _, suffix := range []string{"-wal", "-shm"} {
        src := dbPath + suffix
        if !fileExists(src) {
            continue
        }
        if err := copyFile(src, tmpDB+suffix); err != nil {
            logging.Debug(ctx, "skipping cursor store.db sidecar copy",
                slog.String("file", src), slog.String("error", err.Error()))
        }
    }

cctx, cancel := context.WithTimeout(ctx, sqlite3Timeout)
    defer cancel()
    cmd := exec.CommandContext(cctx, "sqlite3", tmpDB, storeDBBlobQuery)
    out, err := cmd.Output()
    if err != nil {
        var exitErr *exec.ExitError
        if errors.As(err, &exitErr) {
            stderr := strings.TrimSpace(string(exitErr.Stderr))
            if isSchemaMismatch(stderr) {
                logging.Debug(ctx, "cursor store.db schema not recognized; skipping",
                    slog.String("detail", stderr))
                return nil, nil
            }
            return nil, fmt.Errorf("sqlite3 query failed: %w: %s", err, stderr)
        }
        return nil, fmt.Errorf("sqlite3 query failed: %w", err)
    }

var blobs []string
    for _, line := range strings.Split(string(out), "\n") {
        if line = strings.TrimSpace(line); line != "" {
            blobs = append(blobs, line)
        }
    }
    return blobs, nil
}

// isSchemaMismatch reports whether a sqlite3 error is a benign schema-shape
// mismatch (a store version whose blob table/columns differ from what the query
// assumes) rather than a genuine failure. Such stores are treated as an expected
// no-op, not an error.
func isSchemaMismatch(stderr string) bool {
    s := strings.ToLower(stderr)
    return strings.Contains(s, "no such table") || strings.Contains(s, "no such column")
}

// detectImageType returns the media type and file extension for known image
// magic bytes, or ("", "") when the bytes are not a recognized image.
func detectImageType(data []byte) (mediaType, ext string) {
    switch {
    case len(data) >= 8 && string(data[:8]) == "\x89PNG\r\n\x1a\n":
        return "image/png", "png"
    case len(data) >= 3 && data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF:
        return "image/jpeg", "jpg"
    case len(data) >= 6 && string(data[:6]) == "GIF89a", len(data) >= 6 && string(data[:6]) == "GIF87a":
        return "image/gif", "gif"
    case len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP":
        return "image/webp", "webp"
    default:
        return "", ""
    }
}

func sqlite3Available() bool {
    _, err := exec.LookPath("sqlite3")
    return err == nil
}

func fileExists(path string) bool {
    // path is built from a workspace glob result plus a filepath.Base-sanitized
    // session id (separators stripped, "."/".." rejected), so no traversal.
    info, err := os.Stat(path) //nolint:gosec // G703 false positive: path is sanitized (see above)
    return err == nil && !info.IsDir()
}

func copyFile(src, dst string) error {
    in, err := os.Open(src) //nolint:gosec // path is an internal, non-user-controlled store location
    if err != nil {
        return fmt.Errorf("open source: %w", err)
    }
    defer func() { _ = in.Close() }()

out, err := os.Create(dst) //nolint:gosec // dst is a temp file we created
    if err != nil {
        return fmt.Errorf("create destination: %w", err)
    }
    if _, err := io.Copy(out, in); err != nil {
        _ = out.Close()
        return fmt.Errorf("copy contents: %w", err)
    }
    if err := out.Close(); err != nil {
        return fmt.Errorf("close destination: %w", err)
    }
    return nil
}

Acmd/entire/cli/agent/cursor/images.go+295

package cursor

import ( "context" "encoding/hex" "os" "os/exec" "path/filepath" "strings" "testing" )

// pngBytes returns a minimal byte slice with a valid PNG magic header, padded so // it is unambiguously an image. func pngBytes(payload string) []byte { return append([]byte("\x89PNG\r\n\x1a\n"), []byte(payload)...) }

func jpegBytes(payload string) []byte { return append([]byte{0xFF, 0xD8, 0xFF, 0xE0}, []byte(payload)...) }

// webpBytes returns a minimal RIFF/WEBP container (RIFF....WEBP) padded past the // header so the store query's magic-byte filter matches it. func webpBytes(payload string) []byte { return append([]byte("RIFF____WEBP"), []byte(payload)...) }

// buildStoreDB writes a Cursor-style store.db at path with a blobs(id, data) // table populated from the given blobs. It shells out to sqlite3 (the same // binary the code under test uses). func buildStoreDB(t *testing.T, path string, blobs map[string][]byte) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } var sb strings.Builder sb.WriteString("CREATE TABLE blobs(id TEXT PRIMARY KEY, data BLOB);\n") for id, data := range blobs { sb.WriteString("INSERT INTO blobs(id,data) VALUES('" + id + "', x'" + hex.EncodeToString(data) + "');\n") } cmd := exec.CommandContext(context.Background(), "sqlite3", path, sb.String()) if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("build store.db: %v: %s", err, out) } }

// setupChatsDir creates ///store.db and points the // test override env at . Returns the transcript path whose base name is // the session id. func setupChatsDir(t *testing.T, sessionID string, blobs map[string][]byte) string { t.Helper() chats := t.TempDir() dbPath := filepath.Join(chats, "workspace-hash", sessionID, "store.db") buildStoreDB(t, dbPath, blobs) t.Setenv(cursorChatsDirEnv, chats) // Transcript path can be anywhere; only its base name (the session id) matters. return filepath.Join(t.TempDir(), sessionID+".jsonl") }

func requireSqlite3(t *testing.T) { t.Helper() if _, err := exec.LookPath("sqlite3"); err != nil { t.Skip("sqlite3 not installed; skipping cursor store.db test") } }

func TestSidecarImages_CapturesImageBlobs(t *testing.T) { requireSqlite3(t)

img := pngBytes("cursor-sidecar-image-payload-aaaaaaaaaaaaaaaaaaaa") transcriptPath := setupChatsDir(t, "sess-img", map[string][]byte{ "img1": img, "txt1": []byte("this is just some message text, not an image at all"), })

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("SidecarImages: %v", err) } if len(assets) != 1 { t.Fatalf("expected 1 image asset, got %d", len(assets)) } if assets[0].MediaType != "image/png" { t.Errorf("media type = %q, want image/png", assets[0].MediaType) } if string(assets[0].Data) != string(img) { t.Error("captured bytes do not match the stored image blob") } if !strings.HasPrefix(assets[0].Name, "img-") || !strings.HasSuffix(assets[0].Name, ".png") { t.Errorf("asset name %q is not img-.png", assets[0].Name) } }

func TestSidecarImages_MixedImageTypes(t *testing.T) { requireSqlite3(t)

transcriptPath := setupChatsDir(t, "sess-mixed", map[string][]byte{ "a": pngBytes(strings.Repeat("p", 40)), "b": jpegBytes(strings.Repeat("j", 40)), "c": []byte("not an image"), })

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("SidecarImages: %v", err) } if len(assets) != 2 { t.Fatalf("expected 2 image assets, got %d", len(assets)) } types := map[string]bool{} for _, a := range assets { types[a.MediaType] = true } if !types["image/png"] || !types["image/jpeg"] { t.Errorf("expected png and jpeg, got %v", types) } }

func TestSidecarImages_CapturesWebp(t *testing.T) { requireSqlite3(t)

img := webpBytes(strings.Repeat("w", 40)) transcriptPath := setupChatsDir(t, "sess-webp", map[string][]byte{"w1": img})

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("SidecarImages: %v", err) } if len(assets) != 1 { t.Fatalf("expected 1 webp asset (end-to-end through the SQL magic filter), got %d", len(assets)) } if assets[0].MediaType != "image/webp" || !strings.HasSuffix(assets[0].Name, ".webp") { t.Errorf("got %q / %q, want image/webp / *.webp", assets[0].MediaType, assets[0].Name) } }

// A store whose schema is not the expected blobs(data) shape (a future/older // Cursor version) must be a silent no-op, not an error that would log a warning // on every checkpoint. func TestSidecarImages_UnknownSchemaIsNoOp(t *testing.T) { requireSqlite3(t)

chats := t.TempDir() dbPath := filepath.Join(chats, "workspace-hash", "sess-schema", "store.db") if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil { t.Fatalf("mkdir: %v", err) } // No blobs table at all — a different schema shape. cmd := exec.CommandContext(context.Background(), "sqlite3", dbPath, "CREATE TABLE messages(id TEXT, body TEXT); INSERT INTO messages VALUES('a','hi');") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("build store.db: %v: %s", err, out) } t.Setenv(cursorChatsDirEnv, chats) transcriptPath := filepath.Join(t.TempDir(), "sess-schema.jsonl")

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("unexpected error for unrecognized schema (should be a silent no-op): %v", err) } if len(assets) != 0 { t.Fatalf("expected no assets from an unrecognized schema, got %d", len(assets)) } }

func TestSidecarImages_DedupsIdenticalImages(t *testing.T) { requireSqlite3(t)

img := pngBytes(strings.Repeat("dedup", 20)) transcriptPath := setupChatsDir(t, "sess-dup", map[string][]byte{ "one": img, "two": img, // identical content under a different blob id })

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("SidecarImages: %v", err) } if len(assets) != 1 { t.Fatalf("expected identical images deduped to 1, got %d", len(assets)) } }

func TestSidecarImages_TextOnlyStoreReturnsNothing(t *testing.T) { requireSqlite3(t)

transcriptPath := setupChatsDir(t, "sess-text", map[string][]byte{ "m1": []byte("first message"), "m2": []byte("second message"), })

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("SidecarImages: %v", err) } if len(assets) != 0 { t.Fatalf("expected no assets from a text-only store, got %d", len(assets)) } }

func TestSidecarImages_NoStoreDBIsNoOp(t *testing.T) { // Point at an empty chats dir: no store.db for any session. t.Setenv(cursorChatsDirEnv, t.TempDir()) transcriptPath := filepath.Join(t.TempDir(), "missing-session.jsonl")

assets, err := (&CursorAgent{}).SidecarImages(context.Background(), transcriptPath) if err != nil { t.Fatalf("SidecarImages: %v", err) } if assets != nil { t.Fatalf("expected nil assets when no store.db exists, got %d", len(assets)) } }

func TestSidecarImages_EmptySessionRefIsNoOp(t *testing.T) { assets, err := (&CursorAgent{}).SidecarImages(context.Background(), "") if err != nil { t.Fatalf("SidecarImages: %v", err) } if assets != nil { t.Fatal("expected nil assets for empty session ref") } }

func TestSessionIDFromTranscriptPath(t *testing.T) { t.Parallel() cases := map[string]string{ "/home/u/.cursor/projects/p/agent-transcripts/abc-123.jsonl": "abc-123", "/home/u/.cursor/projects/p/agent-transcripts/abc-123/abc-123.jsonl": "abc-123", "": "", "bare.jsonl": "bare", } for in, want := range cases { if got := sessionIDFromTranscriptPath(in); got != want { t.Errorf("sessionIDFromTranscriptPath(%q) = %q, want %q", in, got, want) } } }

func TestDetectImageType(t *testing.T) { t.Parallel() cases := []struct { name string data []byte mediaType string ext string }{ {"png", pngBytes("x"), "image/png", "png"}, {"jpeg", jpegBytes("x"), "image/jpeg", "jpg"}, {"gif89", []byte("GIF89a...."), "image/gif", "gif"}, {"gif87", []byte("GIF87a...."), "image/gif", "gif"}, {"webp", append([]byte("RIFF____WEBP"), []byte("data")...), "image/webp", "webp"}, {"text", []byte("hello world not an image"), "", ""}, {"tooShort", []byte{0x89, 0x50}, "", ""}, } for _, tc := range cases { mt, ext := detectImageType(tc.data) if mt != tc.mediaType || ext != tc.ext { t.Errorf("%s: detectImageType = (%q,%q), want (%q,%q)", tc.name, mt, ext, tc.mediaType, tc.ext) } } }


Acmd/entire/cli/agent/cursor/images\_test.go+263

21 unmodified lines

22 23 24 25 26 27 28

21 unmodified lines

CheckpointInfo = apicheckpoint.CheckpointInfo SessionContent = apicheckpoint.SessionContent SessionFilePaths = apicheckpoint.SessionFilePaths TranscriptAsset = apicheckpoint.TranscriptAsset SessionMetrics = apicheckpoint.SessionMetrics Summary = apicheckpoint.Summary LearningsSummary = apicheckpoint.LearningsSummary


Mcmd/entire/cli/checkpoint/aliases.go+1

3 unmodified lines

4 5 6 7 8 9 10 17 unmodified lines

28 29 30 31 32 33 34 363 unmodified lines

398 399 400 399 401 402 403 404 405 403 404 405 406 407 408 409 410 411 412 413 414 406 407 408 409 410 411 412 413 414 415 416 417 416 418 419 418 419 420 420 421 422 423 424 425 426 427 428 429 422 423 424 425 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 258 unmodified lines

708 709 710 711 712 713 714 715 716 717 718 719 720 781 unmodified lines

1502 1503 1504 1477 1505 1506 1507 1508 332 unmodified lines

1841 1842 1843 1816 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 17 unmodified lines

1980 1981 1982 1840 1983 1984 1985 1986 14 unmodified lines

2001 2002 2003 1861 2004 2005 2006 2007 2008 2009 1867 2010 2011 2012 2013 16 unmodified lines

2030 2031 2032 1890 2033 2034 2035 2036 28 unmodified lines

2065 2066 2067 1925 2068 2069 2070 1928 2071 2072 2073 2074

3 unmodified lines

"bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" 17 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/trailers" transcriptcompact "github.com/entireio/cli/cmd/entire/cli/transcript/compact" "github.com/entireio/cli/cmd/entire/cli/transcript/imageextract" "github.com/entireio/cli/cmd/entire/cli/validation" "github.com/entireio/cli/cmd/entire/cli/vercelconfig" "github.com/entireio/cli/cmd/entire/cli/versioninfo" 363 unmodified lines

agentType = sessionMeta.Agent } } if err := s.replaceTranscript(ctx, opts.Transcript, agentType, startLine, opts.PrecomputedBlobs, sessionDir, entries); err != nil { rewrote, err := s.replaceTranscript(ctx, opts.Transcript, agentType, startLine, opts.PrecomputedBlobs, sessionDir, entries) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to replace transcript: %w", err) }

// Keep the root metadata.json compact_transcript pointer consistent with // the finalized tree. replaceTranscript may have written transcript.jsonl // that the initial write lacked (e.g. compaction was skipped then and // succeeds now), so re-derive the pointer from the tree entry and rewrite // the root summary when it changed. compactPath := "" if _, ok := entries[checkpointSubtreePath(sessionDir, paths.CompactTranscriptFileName)]; ok { compactPath = "/" + checkpointSubtreePath(sessionDir, paths.CompactTranscriptFileName) } if checkpointSummary.Sessions[sessionIndex].CompactTranscript != compactPath { checkpointSummary.Sessions[sessionIndex].CompactTranscript = compactPath summaryJSON, err := jsonutil.MarshalIndentWithNewline(checkpointSummary, "", " ") // Only touch assets and the root pointers when the transcript was actually // rewritten. If replaceTranscript short-circuited (identical content), the // stored transcript, compact, and assets are all unchanged and already // consistent — clearing/rewriting assets here would strip the blobs a // still-present placeholder depends on, leaving a dangling placeholder. if rewrote { // Keep the externalized image assets consistent with the replaced // transcript: write the new set (clearing any stale ones), so a finalize // that re-externalizes matches its placeholders and one that produces an // inline transcript leaves no orphaned blobs. manifestPath, err := s.writeAssetsForBackfill(opts, sessionDir, entries) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to marshal checkpoint summary: %w", err) return plumbing.ZeroHash, fmt.Errorf("failed to write assets: %w", err) } summaryHash, err := CreateBlobFromContent(s.repo, summaryJSON) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create checkpoint summary blob: %w", err)

// Keep the root metadata.json compact_transcript and assets_manifest // pointers consistent with the finalized tree. replaceTranscript may have // written transcript.jsonl that the initial write lacked (e.g. compaction // was skipped then and succeeds now), so re-derive both pointers from the // tree and rewrite the root summary once when either changed. compactPath := "" if _, ok := entries[checkpointSubtreePath(sessionDir, paths.CompactTranscriptFileName)]; ok { compactPath = "/" + checkpointSubtreePath(sessionDir, paths.CompactTranscriptFileName) } entries[rootMetadataPath] = object.TreeEntry{ Name: rootMetadataPath, Mode: filemode.Regular, Hash: summaryHash, sess := &checkpointSummary.Sessions[sessionIndex] if sess.CompactTranscript != compactPath || sess.AssetsManifest != manifestPath { sess.CompactTranscript = compactPath sess.AssetsManifest = manifestPath summaryJSON, err := jsonutil.MarshalIndentWithNewline(checkpointSummary, "", " ") if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to marshal checkpoint summary: %w", err) } summaryHash, err := CreateBlobFromContent(s.repo, summaryJSON) if err != nil { return plumbing.ZeroHash, fmt.Errorf("failed to create checkpoint summary blob: %w", err) } entries[rootMetadataPath] = object.TreeEntry{ Name: rootMetadataPath, Mode: filemode.Regular, Hash: summaryHash, } } } } 258 unmodified lines

} }

// Write externalized image assets (raw binary blobs + manifest), when present. manifestPath, err := s.writeAssets(opts.Assets, sessionDir, entries) if err != nil { return filePaths, err } filePaths.AssetsManifest = manifestPath

// Write prompts via the 7-layer pipeline. OPF runs only in the // pre-push rewrite path (manual_commit_opf_rewrite.go). if len(opts.Prompts) > 0 { 781 unmodified lines

// Read transcript (auto-fetches blobs if needed) if transcript, transcriptErr := readTranscriptFromTree(ctx, sessionTree, agentType); transcriptErr == nil && transcript != nil { result.Transcript = transcript result.Transcript = reinjectAssets(sessionTree, agentType, transcript) result.TranscriptBlobHashes = transcriptBlobHashesFromTreeEntries(sessionTree.RawEntries()) }

332 unmodified lines

// reuse precomputed blobs: each checkpoint in a turn shares the full // transcript but has its own start offset, so the compact content differs per // checkpoint. func (s *treeWriter) replaceTranscript(ctx context.Context, transcript redact.RedactedBytes, agentType types.AgentType, startLine int, precomputed *PrecomputedTranscriptBlobs, sessionDir string, entries map[string]object.TreeEntry) error { // assetManifestEntry describes one externalized asset in assets/manifest.json. // Size and SHA256 are descriptive metadata for external tooling and audits; they // are not used on reinject (git content-addresses the blobs, which already // guarantees their integrity on read). type assetManifestEntry struct { Name string json:"name" MediaType string json:"media_type,omitempty" Size int json:"size" SHA256 string json:"sha256" }

// writeAssetsForBackfill writes the update's assets, but preserves any // already-stored assets when the update carries none AND the update opts into // preservation (UpdateOptions.PreserveAssetsWhenEmpty). This guards a best-effort // sidecar capture (e.g. Cursor's sqlite3 store read) that transiently yields // nothing at finalize from wiping images a prior CondenseSession successfully // stored: leaving the existing assets/ subtree untouched is strictly safer than // clearing it. Returns the (possibly pre-existing) manifest path. func (s *treeWriter) writeAssetsForBackfill(opts UpdateOptions, sessionDir string, entries map[string]object.TreeEntry) (string, error) { if len(opts.Assets) == 0 && opts.PreserveAssetsWhenEmpty { manifestKey := checkpointSubtreePath(sessionDir, paths.AssetsManifestFile) if _, ok := entries[manifestKey]; ok { return "/" + manifestKey, nil } return "", nil } return s.writeAssets(opts.Assets, sessionDir, entries) }

// writeAssets stores each externalized transcript asset as a raw binary blob // under the session's assets/ folder, plus an assets/manifest.json index, in the // same tree. Returns the manifest path ("" when there are no assets). git // content-addresses the blobs, so identical images dedupe across checkpoints. // // It first clears any assets already present under the session's assets/ folder, // so a re-write (backfill/finalize) replaces rather than accumulates, and an // empty asset set leaves no orphaned blobs behind a now-inline transcript. func (s *treeWriter) writeAssets(assets []TranscriptAsset, sessionDir string, entries map[string]object.TreeEntry) (string, error) { assetsPrefix := checkpointSubtreePath(sessionDir, paths.AssetsDirName) + "/" for key := range entries { if strings.HasPrefix(key, assetsPrefix) { delete(entries, key) } } if len(assets) == 0 { return "", nil } manifest := struct { Version int json:"version" Assets []assetManifestEntry json:"assets" }{Version: 1} for _, a := range assets { blobHash, err := CreateBlobFromContent(s.repo, a.Data) if err != nil { return "", err } p := checkpointSubtreePath(sessionDir, paths.AssetsDirName, a.Name) entries[p] = object.TreeEntry{Name: p, Mode: filemode.Regular, Hash: blobHash} sum := sha256.Sum256(a.Data) manifest.Assets = append(manifest.Assets, assetManifestEntry{ Name: a.Name, MediaType: a.MediaType, Size: len(a.Data), SHA256: hex.EncodeToString(sum[:]), }) } manifestJSON, err := jsonutil.MarshalIndentWithNewline(manifest, "", " ") if err != nil { return "", fmt.Errorf("marshal assets manifest: %w", err) } manifestHash, err := CreateBlobFromContent(s.repo, manifestJSON) if err != nil { return "", err } mp := checkpointSubtreePath(sessionDir, paths.AssetsManifestFile) entries[mp] = object.TreeEntry{Name: mp, Mode: filemode.Regular, Hash: manifestHash} return "/" + mp, nil }

// reinjectAssets restores externalized images into a transcript on read, so the // returned bytes match what was stored. Best-effort and gated on placeholder // presence, not on any config flag: an asset it can't load is left as a // placeholder rather than failing the read. func reinjectAssets(sessionTree *FetchingTree, agentType types.AgentType, transcript []byte) []byte { if !imageextract.HasPlaceholders(transcript) { return transcript } codec := imageextract.CodecFor(agentType) if codec == nil { return transcript } // No blob-integrity check is needed here: git content-addresses every asset // blob, so a corrupt/truncated fetch fails object verification and Contents() // errors out (leaving the placeholder). The manifest's sha256 is external // metadata, not a second integrity gate — and since writeAssets derives both // the blob and the sha256 from the same bytes, they can never disagree. out, err := codec.ReinjectImages(transcript, func(name string) (agent.CompactedTranscriptAsset, bool) { f, ferr := sessionTree.File(paths.AssetsDir + name) if ferr != nil { return agent.CompactedTranscriptAsset{}, false } content, cerr := f.Contents() if cerr != nil { return agent.CompactedTranscriptAsset{}, false } return agent.CompactedTranscriptAsset{Name: name, Data: []byte(content)}, true }) if err != nil { return transcript } return out }

// replaceTranscript rewrites the session transcript (full.jsonl chunks + // content_hash + compact) in entries. It reports whether it actually rewrote: // false means the content-hash matched and everything was left as-is (the // caller must then leave coupled artifacts like assets untouched too, so they // stay consistent with the unchanged transcript). func (s *treeWriter) replaceTranscript(ctx context.Context, transcript redact.RedactedBytes, agentType types.AgentType, startLine int, precomputed *PrecomputedTranscriptBlobs, sessionDir string, entries map[string]object.TreeEntry) (bool, error) { // Ignore precompute if invariants are violated — fall back to fresh chunking. if precomputed != nil && !precomputed.IsUsable() { precomputed = nil 17 unmodified lines

existingHash, readErr := io.ReadAll(rdr) _ = rdr.Close() if readErr == nil && string(existingHash) == newContentHash { return nil return false, nil } } } 14 unmodified lines

} else { chunks, err := chunkTranscript(ctx, transcript.Bytes(), agentType) if err != nil { return fmt.Errorf("failed to chunk transcript: %w", err) return false, fmt.Errorf("failed to chunk transcript: %w", err) } chunkHashes = make([]plumbing.Hash, len(chunks)) for i, chunk := range chunks { blobHash, err := CreateBlobFromContent(s.repo, chunk) if err != nil { return fmt.Errorf("failed to create transcript blob: %w", err) return false, fmt.Errorf("failed to create transcript blob: %w", err) } chunkHashes[i] = blobHash } 16 unmodified lines

} else { h, err := CreateBlobFromContent(s.repo, []byte(newContentHash)) if err != nil { return fmt.Errorf("failed to create content hash blob: %w", err) return false, fmt.Errorf("failed to create content hash blob: %w", err) } hashBlob = h } 28 unmodified lines

// transcript.jsonl: record the new boundary when one was produced, or clear // it (nil) when the compact transcript was dropped above. if err := s.setCompactTranscriptStart(sessionDir, compactStart, entries); err != nil { return fmt.Errorf("failed to update compact transcript start: %w", err) return false, fmt.Errorf("failed to update compact transcript start: %w", err) }

return nil return true, nil }

// PrecomputeTranscriptBlobs chunks the given transcript and writes each chunk


Mcmd/entire/cli/checkpoint/persistent.go+172/-29

package checkpoint

import (
    "context"
    "encoding/base64"
    "strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
    "github.com/entireio/cli/cmd/entire/cli/paths"
    "github.com/entireio/cli/cmd/entire/cli/transcript/imageextract"
    "github.com/entireio/cli/redact"
)

// claudeTranscriptWithImage returns a Claude Code JSONL transcript whose first
// line embeds an inline base64 image, followed by an ordinary assistant reply.
// It returns the raw (image-inline) bytes plus the base64 string so tests can
// assert on both the extracted and reinjected forms.
func claudeTranscriptWithImage(t *testing.T) (raw []byte, b64 string) {
    t.Helper()
    b64 = base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nround-trip-fixture-bytes-long-enough-to-be-externalized\x00\x01\x02\x03"))
    lines := []string{
        `{"type":"user","uuid":"u1","timestamp":"2026-01-01T00:00:00Z","message":{"role":"user","content":[` +\
            `{"type":"text","text":"look at this"},` +\
            `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + b64 + `"}}` +\
            `]}}`,
        `{"type":"assistant","uuid":"a1","timestamp":"2026-01-01T00:00:01Z","message":{"id":"msg_1","role":"assistant","content":[{"type":"text","text":"nice screenshot"}],"usage":{"input_tokens":5,"output_tokens":7}}}`,
    }
    return []byte(strings.Join(lines, "\n") + "\n"), b64
}

// claudeImagePayload builds a one-image Claude Code transcript from a distinct
// payload, returning the raw inline bytes and the base64 string.
func claudeImagePayload(t *testing.T, payload string) (raw []byte, b64 string) {
    t.Helper()
    b64 = base64.StdEncoding.EncodeToString([]byte(payload + "-padded-so-the-base64-clears-the-externalize-threshold"))
    line := `{"type":"user","message":{"role":"user","content":[` +\
        `{"type":"text","text":"look"},` +\
        `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + b64 + `"}}` +\
        `]}}`
    return []byte(line + "\n"), b64
}

// externalize runs the codec the way the condensation/finalize paths do.
func externalize(t *testing.T, raw []byte) (rewritten []byte, assets []TranscriptAsset) {
    t.Helper()
    codec := imageextract.CodecFor(agent.AgentTypeClaudeCode)
    rw, ex, err := codec.ExtractImages(raw)
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    out := make([]TranscriptAsset, len(ex))
    for i, a := range ex {
        out[i] = TranscriptAsset{Name: a.Name, MediaType: a.MediaType, Data: a.Data}
    }
    return rw, out
}

// TestAssets_BackfillReExternalizesAndReplacesAssets is the S1 regression: the
// stop-hook finalize path (backfillTranscript / SessionTranscript) must persist a
// newly-externalized transcript and its assets, replacing the condense-time
// assets rather than orphaning them or re-inlining the images.
func TestAssets_BackfillReExternalizesAndReplacesAssets(t *testing.T) {
    t.Parallel()
    repo, _ := setupTestRepo(t)
    store := NewGitStore(repo, DefaultV1Refs())
    cpID := id.MustCheckpointID("a55e70000010")
    sessionPath := cpID.Path() + "/0/"

// Condense: first (mid-turn) externalized write.
    rawA, _ := claudeImagePayload(t, "condense-image")
    rewrittenA, assetsA := externalize(t, rawA)
    if len(assetsA) != 1 {
        t.Fatalf("want 1 asset from condense, got %d", len(assetsA))
    }
    if err := store.Write(context.Background(), Session{
        CheckpointID: cpID, SessionID: "s-backfill", Strategy: "manual-commit",
        Transcript: redact.AlreadyRedacted(rewrittenA), Assets: assetsA,
        Agent: agent.AgentTypeClaudeCode, AuthorName: "T", AuthorEmail: "t@t.com",
    }); err != nil {
        t.Fatalf("condense Write: %v", err)
    }

// Finalize: backfill with a different, longer externalized transcript.
    rawB, b64B := claudeImagePayload(t, "finalize-different-image-with-more-bytes")
    rewrittenB, assetsB := externalize(t, rawB)
    if err := store.Write(context.Background(), SessionTranscript{
        CheckpointID: cpID, SessionID: "s-backfill",
        Transcript: redact.AlreadyRedacted(rewrittenB), Assets: assetsB,
        Agent: agent.AgentTypeClaudeCode,
    }); err != nil {
        t.Fatalf("backfill Write: %v", err)
    }

// Stored full.jsonl carries B's placeholder, not raw base64; the old asset
    // blob is gone and B's is present.
    stored, ok := readBranchFile(t, store, sessionPath+paths.TranscriptFileName)
    if !ok {
        t.Fatal("full.jsonl missing")
    }
    if strings.Contains(stored, b64B) {
        t.Error("stored transcript still contains raw base64 after backfill")
    }
    if !strings.Contains(stored, "entire-asset:assets/"+assetsB[0].Name) {
        t.Error("stored transcript missing backfilled placeholder")
    }
    if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsDir+assetsA[0].Name); ok {
        t.Error("stale condense-time asset blob was not cleared on backfill")
    }
    if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsDir+assetsB[0].Name); !ok {
        t.Error("backfilled asset blob missing")
    }

// Manifest pointer updated; restore round-trips to B byte-exact.
    summary := readSummaryFromBranch(t, repo, cpID)
    if summary.Sessions[0].AssetsManifest != "/"+sessionPath+paths.AssetsManifestFile {
        t.Errorf("assets_manifest pointer = %q, want set", summary.Sessions[0].AssetsManifest)
    }
    content, err := store.ReadSessionContent(context.Background(), cpID, 0)
    if err != nil {
        t.Fatalf("ReadSessionContent: %v", err)
    }
    if string(content.Transcript) != string(rawB) {
        t.Fatalf("backfill round-trip not byte-exact:\n got: %s\nwant: %s", content.Transcript, rawB)
    }
}

// TestAssets_BackfillIdenticalTranscriptKeepsAssets is the short-circuit
// regression: a backfill whose transcript is byte-identical to what is stored
// (so replaceTranscript short-circuits) must NOT clear the assets, even if it is
// called with empty Assets — the still-present placeholder must keep round-tripping.
func TestAssets_BackfillIdenticalTranscriptKeepsAssets(t *testing.T) {
    t.Parallel()
    repo, _ := setupTestRepo(t)
    store := NewGitStore(repo, DefaultV1Refs())
    cpID := id.MustCheckpointID("a55e70000012")
    sessionPath := cpID.Path() + "/0/"

rawA, _ := claudeImagePayload(t, "shortcircuit-image")
    rewrittenA, assetsA := externalize(t, rawA)
    if err := store.Write(context.Background(), Session{
        CheckpointID: cpID, SessionID: "s1", Strategy: "manual-commit",
        Transcript: redact.AlreadyRedacted(rewrittenA), Assets: assetsA,
        Agent: agent.AgentTypeClaudeCode, AuthorName: "T", AuthorEmail: "t@t.com",
    }); err != nil {
        t.Fatalf("first Write: %v", err)
    }

// Backfill with the identical transcript (short-circuit) and NO assets.
    if err := store.Write(context.Background(), SessionTranscript{
        CheckpointID: cpID, SessionID: "s1",
        Transcript: redact.AlreadyRedacted(rewrittenA),
        Agent:      agent.AgentTypeClaudeCode,
    }); err != nil {
        t.Fatalf("second Write: %v", err)
    }

// Assets survive; the placeholder still round-trips to the original image.
    if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsDir+assetsA[0].Name); !ok {
        t.Error("asset blob was cleared by an identical-transcript backfill")
    }
    content, err := store.ReadSessionContent(context.Background(), cpID, 0)
    if err != nil {
        t.Fatalf("ReadSessionContent: %v", err)
    }
    if strings.Contains(string(content.Transcript), "entire-asset:assets/") {
        t.Errorf("dangling placeholder after identical-transcript backfill: %s", content.Transcript)
    }
    if string(content.Transcript) != string(rawA) {
        t.Errorf("restore did not round-trip after identical-transcript backfill")
    }
}

// TestAssets_BackfillInlineClearsStaleAssets covers the flag-off-at-finalize case:
// a backfill with an inline transcript and no assets must clear the assets stored
// at condense time (no orphans) and clear the manifest pointer.
func TestAssets_BackfillInlineClearsStaleAssets(t *testing.T) {
    t.Parallel()
    repo, _ := setupTestRepo(t)
    store := NewGitStore(repo, DefaultV1Refs())
    cpID := id.MustCheckpointID("a55e70000011")
    sessionPath := cpID.Path() + "/0/"

rawA, _ := claudeImagePayload(t, "condense-image")
    rewrittenA, assetsA := externalize(t, rawA)
    if err := store.Write(context.Background(), Session{
        CheckpointID: cpID, SessionID: "s-inline", Strategy: "manual-commit",
        Transcript: redact.AlreadyRedacted(rewrittenA), Assets: assetsA,
        Agent: agent.AgentTypeClaudeCode, AuthorName: "T", AuthorEmail: "t@t.com",
    }); err != nil {
        t.Fatalf("condense Write: %v", err)
    }

// Backfill inline (as if externalization were off at finalize): no Assets.
    rawB, b64B := claudeImagePayload(t, "condense-image") // same content, inline
    if err := store.Write(context.Background(), SessionTranscript{
        CheckpointID: cpID, SessionID: "s-inline",
        Transcript: redact.AlreadyRedacted(rawB),
        Agent:      agent.AgentTypeClaudeCode,
    }); err != nil {
        t.Fatalf("backfill Write: %v", err)
    }

if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsDir+assetsA[0].Name); ok {
        t.Error("stale asset blob not cleared when backfill went inline")
    }
    if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsManifestFile); ok {
        t.Error("manifest not cleared when backfill went inline")
    }
    summary := readSummaryFromBranch(t, repo, cpID)
    if summary.Sessions[0].AssetsManifest != "" {
        t.Errorf("assets_manifest pointer = %q, want empty", summary.Sessions[0].AssetsManifest)
    }
    stored, _ := readBranchFile(t, store, sessionPath+paths.TranscriptFileName)
    if !strings.Contains(stored, b64B) {
        t.Error("inline backfill should store raw base64")
    }
    content, err := store.ReadSessionContent(context.Background(), cpID, 0)
    if err != nil {
        t.Fatalf("ReadSessionContent: %v", err)
    }
    if string(content.Transcript) != string(rawB) {
        t.Errorf("inline backfill restore mismatch")
    }
}

// TestAssets_StoreRestoreRoundTrip is the end-to-end contract for image
// externalization at the persistent-store layer: a Claude Code transcript with an
// inline base64 image is externalized before the write, stored as a placeholder
// plus an assets/ blob and manifest, and reinjected byte-exactly on read.
func TestAssets_StoreRestoreRoundTrip(t *testing.T) {
    t.Parallel()
    repo, _ := setupTestRepo(t)
    store := NewGitStore(repo, DefaultV1Refs())
    cpID := id.MustCheckpointID("a55e70000001")

raw, b64 := claudeTranscriptWithImage(t)

// Externalize exactly as the condensation path does, then store the
    // placeholder-bearing transcript with its assets.
    codec := imageextract.CodecFor(agent.AgentTypeClaudeCode)
    if codec == nil {
        t.Fatal("expected a Claude Code image codec")
    }
    rewritten, assets, err := codec.ExtractImages(raw)
    if err != nil {
        t.Fatalf("ExtractImages() error = %v", err)
    }
    if len(assets) != 1 {
        t.Fatalf("expected 1 externalized asset, got %d", len(assets))
    }
    writeAssets := make([]TranscriptAsset, len(assets))
    for i, a := range assets {
        writeAssets[i] = TranscriptAsset{Name: a.Name, MediaType: a.MediaType, Data: a.Data}
    }

if err := store.Write(context.Background(), Session{
        CheckpointID: cpID,
        SessionID:    "session-assets-001",
        Strategy:     "manual-commit",
        Transcript:   redact.AlreadyRedacted(rewritten),
        Assets:       writeAssets,
        Prompts:      []string{"look at this"},
        Agent:        agent.AgentTypeClaudeCode,
        AuthorName:   "Test",
        AuthorEmail:  "test@test.com",
    }); err != nil {
        t.Fatalf("Write() error = %v", err)
    }

sessionPath := cpID.Path() + "/0/"

// Stored full.jsonl carries the placeholder, not the raw base64.
    stored, ok := readBranchFile(t, store, sessionPath+paths.TranscriptFileName)
    if !ok {
        t.Fatal("full.jsonl missing from checkpoint tree")
    }
    if strings.Contains(stored, b64) {
        t.Error("stored full.jsonl still contains raw base64 image data")
    }
    if !strings.Contains(stored, "entire-asset:assets/"+assets[0].Name) {
        t.Errorf("stored full.jsonl missing placeholder for %s", assets[0].Name)
    }

// The asset blob and manifest are written under assets/.
    if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsDir+assets[0].Name); !ok {
        t.Errorf("asset blob %s missing from checkpoint tree", assets[0].Name)
    }
    manifest, ok := readBranchFile(t, store, sessionPath+paths.AssetsManifestFile)
    if !ok {
        t.Fatal("assets/manifest.json missing from checkpoint tree")
    }
    if !strings.Contains(manifest, assets[0].Name) || !strings.Contains(manifest, `"media_type": "image/png"`) {
        t.Errorf("manifest missing expected asset entry: %s", manifest)
    }

// Session metadata points at the manifest.
    summary := readSummaryFromBranch(t, repo, cpID)
    if len(summary.Sessions) != 1 {
        t.Fatalf("session count = %d, want 1", len(summary.Sessions))
    }
    wantManifest := "/" + sessionPath + paths.AssetsManifestFile
    if summary.Sessions[0].AssetsManifest != wantManifest {
        t.Errorf("sessions[0].assets_manifest = %q, want %q", summary.Sessions[0].AssetsManifest, wantManifest)
    }

// Read back: the image is reinjected byte-exactly, reproducing the original.
    content, err := store.ReadSessionContent(context.Background(), cpID, 0)
    if err != nil {
        t.Fatalf("ReadSessionContent() error = %v", err)
    }
    if strings.Contains(string(content.Transcript), "entire-asset:assets/") {
        t.Error("restored transcript still contains a placeholder")
    }
    if !strings.Contains(string(content.Transcript), b64) {
        t.Error("restored transcript missing reinjected base64 image")
    }
    if string(content.Transcript) != string(raw) {
        t.Fatalf("round-trip not byte-exact:\n got: %s\nwant: %s", content.Transcript, raw)
    }
}

// TestAssets_NoExternalizationWritesNoManifest confirms the default (no assets)
// path is unchanged: no assets/ folder and an empty AssetsManifest pointer.
func TestAssets_NoExternalizationWritesNoManifest(t *testing.T) {
    t.Parallel()
    repo, _ := setupTestRepo(t)
    store := NewGitStore(repo, DefaultV1Refs())
    cpID := id.MustCheckpointID("a55e70000002")

if err := store.Write(context.Background(), Session{
        CheckpointID: cpID,
        SessionID:    "session-assets-002",
        Strategy:     "manual-commit",
        Transcript:   redact.AlreadyRedacted(claudeStyleTranscript()),
        Prompts:      []string{"hello one"},
        Agent:        agent.AgentTypeClaudeCode,
        AuthorName:   "Test",
        AuthorEmail:  "test@test.com",
    }); err != nil {
        t.Fatalf("Write() error = %v", err)
    }

sessionPath := cpID.Path() + "/0/"
    if _, ok := readBranchFile(t, store, sessionPath+paths.AssetsManifestFile); ok {
        t.Error("assets/manifest.json should not be written when there are no assets")
    }
    summary := readSummaryFromBranch(t, repo, cpID)
    if len(summary.Sessions) != 1 {
        t.Fatalf("session count = %d, want 1", len(summary.Sessions))
    }
    if summary.Sessions[0].AssetsManifest != "" {
        t.Errorf("sessions[0].assets_manifest = %q, want empty", summary.Sessions[0].AssetsManifest)
    }
}

Acmd/entire/cli/checkpoint/persistent_assets_test.go+356

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

//go:build integration

package integration

import (
    "context"
    "encoding/base64"
    "encoding/json"
    "os"
    "path/filepath"
    "strings"
    "testing"

"github.com/entireio/cli/cmd/entire/cli/checkpoint"
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
    "github.com/entireio/cli/cmd/entire/cli/gitrepo"
    "github.com/entireio/cli/cmd/entire/cli/paths"
)

// TestCodexImageExternalization_FullHookFlow is the Codex end-to-end proof: it
// drives the real Codex hook binary (user-prompt-submit -> apply_patch
// post-tool-use -> mid-turn commit condensation -> stop finalize) on a Codex
// rollout transcript that embeds an image as a data-URI, with externalization
// enabled via settings.local.json. It then asserts the actual
// entire/checkpoints/v1 ref stores a placeholder (not the raw base64) that
// survives Codex's SanitizePortableTranscript, writes the asset blob + manifest,
// and that ReadSessionContent reinjects the image byte-exactly.
func TestCodexImageExternalization_FullHookFlow(t *testing.T) {
    env := NewFeatureBranchEnv(t)

localSettings := filepath.Join(env.RepoDir, ".entire", "settings.local.json")
    if err := os.WriteFile(localSettings, []byte(`{"redaction":{"externalize_images":true}}`), 0o644); err != nil {
        t.Fatalf("write settings.local.json: %v", err)
    }

// A real, minimal PNG padded past the externalization length threshold.
    imgBytes := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("codex-real-e2e-image-payload-", 4))
    b64 := base64.StdEncoding.EncodeToString(imgBytes)

sessionID := "codex-image-e2e"
    transcriptPath := filepath.Join(env.RepoDir, ".entire", "tmp", "codex-rollout.jsonl")

// A Codex rollout: session meta, then a user message with an inline image
    // data-URI (the confirmed real format), then an assistant reply.
    rollout := strings.Join([]string{
        `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + env.RepoDir + `"}}`,
        `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[` +\
            `{"type":"input_text","text":"add feature.txt and look at this screenshot"},` +\
            `{"type":"input_image","image_url":"data:image/png;base64,` + b64 + `"}` +\
            `]}}`,
        `{"timestamp":"2026-01-01T00:00:02Z","type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"done"}]}}`,
    }, "\n") + "\n"
    if err := os.MkdirAll(filepath.Dir(transcriptPath), 0o755); err != nil {
        t.Fatalf("mkdir: %v", err)
    }
    if err := os.WriteFile(transcriptPath, []byte(rollout), 0o644); err != nil {
        t.Fatalf("write rollout: %v", err)
    }

runner := NewCodexHookRunner(env.RepoDir, t)
    hook := func(name string, extra map[string]any) {
        t.Helper()
        in := map[string]any{
            "session_id":      sessionID,
            "transcript_path": transcriptPath,
            "cwd":             env.RepoDir,
            "model":           "gpt-5",
            "permission_mode": "default",
        }
        for k, v := range extra {
            in[k] = v
        }
        b, err := json.Marshal(in)
        if err != nil {
            t.Fatalf("marshal %s input: %v", name, err)
        }
        if err := runner.runCodexHook(name, b); err != nil {
            t.Fatalf("codex hook %s: %v", name, err)
        }
    }

// Turn start (creates the Codex session), then a file-mutating tool use so the
    // commit has attributable content.
    hook("user-prompt-submit", map[string]any{"prompt": "add feature.txt and look at this screenshot", "hook_event_name": "UserPromptSubmit"})
    patch := "*** Begin Patch\n*** Add File: feature.txt\n+hi\n*** End Patch\n"
    hook("post-tool-use", map[string]any{
        "hook_event_name": "PostToolUse", "tool_name": "apply_patch",
        "tool_use_id": "call_1", "tool_input": map[string]string{"command": patch}, "tool_response": "Success.",
    })

// Mid-turn commit -> post-commit condensation externalizes; stop -> finalize.
    env.WriteFile("feature.txt", "hi\n")
    env.GitCommitWithShadowHooks("add feature.txt", "feature.txt")
    hook("stop", map[string]any{"hook_event_name": "Stop"})

if !env.BranchExists(paths.MetadataBranchName) {
        t.Fatal("entire/checkpoints/v1 should exist after Codex condensation")
    }
    cpID := env.GetLatestCheckpointIDFromHistory()
    if cpID == "" {
        t.Fatal("no checkpoint id in history")
    }
    sessionPath := ShardedCheckpointPath(cpID) + "/0/"

full, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.TranscriptFileName)
    if !ok {
        t.Fatalf("full.jsonl missing at %s", sessionPath)
    }
    if strings.Contains(full, b64) {
        t.Error("stored full.jsonl still contains the raw base64 image (externalization did not persist)")
    }
    if !strings.Contains(full, "entire-asset:assets/") {
        t.Error("stored full.jsonl has no image placeholder")
    }
    if !strings.Contains(full, "data:image/png;base64,entire-asset:assets/") {
        t.Error("expected the placeholder inside the data-URI (prefix preserved)")
    }
    if _, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile); !ok {
        t.Error("assets/manifest.json missing")
    }

// Restore reinjects the image byte-exactly.
    repo, err := gitrepo.OpenPath(env.RepoDir)
    if err != nil {
        t.Fatalf("open repo: %v", err)
    }
    defer repo.Close()
    stores, err := checkpoint.Open(context.Background(), repo, checkpoint.OpenOptions{})
    if err != nil {
        t.Fatalf("open stores: %v", err)
    }
    checkpointID, err := id.NewCheckpointID(cpID)
    if err != nil {
        t.Fatalf("parse checkpoint id: %v", err)
    }
    content, err := stores.Persistent.ReadSessionContent(context.Background(), checkpointID, 0)
    if err != nil {
        t.Fatalf("ReadSessionContent: %v", err)
    }
    if strings.Contains(string(content.Transcript), "entire-asset:assets/") {
        t.Error("restored transcript still has a placeholder (reinjection failed)")
    }
    if !strings.Contains(string(content.Transcript), b64) {
        t.Error("restored transcript is missing the reinjected base64 image")
    }
}

Acmd/entire/cli/integration_test/codex_image_externalize_test.go+146

//go:build integration

package integration

import ( "context" "encoding/hex" "encoding/json" "os" "os/exec" "path/filepath" "strings" "testing"

"github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/paths"

"github.com/stretchr/testify/require" )

// TestCursorImageExternalization_SidecarCapture is the Cursor end-to-end proof. // Cursor keeps pasted images in a per-session SQLite blob store (store.db), NOT // the JSONL transcript Entire condenses, so the transcript codec used for Claude // and Codex cannot reach them. This drives the real Cursor hook flow (session // start -> before-submit-prompt -> mid-turn commit condensation -> stop finalize) // with a store.db that holds an image, externalization enabled, and asserts the // checkpoint captures the image as an asset (blob + manifest) even though the // transcript never contained it and carries no placeholder. func TestCursorImageExternalization_SidecarCapture(t *testing.T) { t.Parallel()

if _, err := exec.LookPath("sqlite3"); err != nil { t.Skip("sqlite3 not installed; skipping cursor store.db capture test") }

env := NewFeatureBranchEnv(t) env.InitEntireWithAgent(agent.AgentNameCursor)

localSettings := filepath.Join(env.RepoDir, ".entire", "settings.local.json") require.NoError(t, os.WriteFile(localSettings, []byte({"redaction":{"externalize_images":true}}), 0o644))

cursorProjectDir := t.TempDir() if resolved, err := filepath.EvalSymlinks(cursorProjectDir); err == nil { cursorProjectDir = resolved } chatsDir := t.TempDir()

// Propagate the cursor project + chats dirs to BOTH the stop-hook subprocess // (via cliEnv) and the git-hook condensation subprocess (via gitHookEnv). env.ExtraEnv = append(env.ExtraEnv, "ENTIRE_TEST_CURSOR_PROJECT_DIR="+cursorProjectDir, "ENTIRE_TEST_CURSOR_CHATS_DIR="+chatsDir, )

const conversationID = "cursor-image-e2e"

// Transcript is text-only — Cursor never inlines the image here. transcriptDir := filepath.Join(cursorProjectDir, conversationID) require.NoError(t, os.MkdirAll(transcriptDir, 0o755)) transcriptPath := filepath.Join(transcriptDir, conversationID+".jsonl") require.NoError(t, os.WriteFile(transcriptPath, []byte({"type":"user","text":"look at this screenshot and add a feature"}+"\n"+ {"type":"assistant","text":"done"}+"\n"), 0o600))

// The image lives only in Cursor's SQLite store, keyed by conversation id at // ///store.db. img := append([]byte("\x89PNG\r\n\x1a\n"), []byte(strings.Repeat("cursor-real-sidecar-image-payload-", 8))...) storeDBPath := filepath.Join(chatsDir, "workspace-hash", conversationID, "store.db") require.NoError(t, os.MkdirAll(filepath.Dir(storeDBPath), 0o755)) buildCursorStoreDB(t, storeDBPath, map[string][]byte{ "img-blob": img, "text-blob": []byte("this is a message body, not an image, and should be ignored"), })

runCursorHook(t, env, cursorProjectDir, "session-start", map[string]any{ "conversation_id": conversationID, "transcript_path": transcriptPath, "model": "cursor-default", }) runCursorHook(t, env, cursorProjectDir, "before-submit-prompt", map[string]any{ "conversation_id": conversationID, "transcript_path": transcriptPath, "prompt": "look at this screenshot and add a feature", })

env.WriteFile("feature.go", "package main\n// new feature\n")

// Stop ends the turn; the commit's condensation then creates the checkpoint // and captures the sidecar image (Cursor has no mid-turn tool hooks, so the // checkpoint is born at commit time, not updated by a later finalize). runCursorHook(t, env, cursorProjectDir, "stop", map[string]any{ "conversation_id": conversationID, "transcript_path": transcriptPath, "model": "cursor-default", "loop_count": 1, }) env.GitCommitWithShadowHooks("Add feature", "feature.go")

cpID := env.TryGetLatestCheckpointID() require.NotEmpty(t, cpID, "expected a condensed checkpoint after commit") sessionPath := ShardedCheckpointPath(cpID) + "/0/"

// The transcript is untouched: no placeholder, no image bytes (there were none). full, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.TranscriptFileName) require.True(t, ok, "full.jsonl missing at %s", sessionPath) require.NotContains(t, full, "entire-asset:", "cursor transcript must not carry a placeholder")

// The manifest indexes the captured image. manifest, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile) require.True(t, ok, "assets/manifest.json missing — sidecar image was not captured") var manifestDoc struct { Version int json:"version" Assets []struct { Name string json:"name" MediaType string json:"media_type" } json:"assets" } require.NoError(t, json.Unmarshal([]byte(manifest), &manifestDoc)) require.Len(t, manifestDoc.Assets, 1, "expected exactly one captured image in the manifest") entry := manifestDoc.Assets[0] require.Equal(t, "image/png", entry.MediaType) require.True(t, strings.HasPrefix(entry.Name, "img-") && strings.HasSuffix(entry.Name, ".png"), "asset name %q is not img-.png", entry.Name)

// The asset blob is stored byte-exact. blob, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsDir+entry.Name) require.True(t, ok, "asset blob %s missing", entry.Name) require.Equal(t, string(img), blob, "stored asset bytes differ from the store.db image") }

// TestCursorImageExternalization_SurvivesFinalizeRewrite guards against the // finalize-wipe regression: when a mid-turn commit's condensation captures a // Cursor sidecar image and a later stop finalizes the checkpoint with a grown // (rewritten) transcript, writeAssets clears the whole assets/ folder before // re-writing. If finalize omitted the sidecar images from its asset set, the // captured image would be permanently dropped. This drives that exact sequence // and asserts the image survives finalize. func TestCursorImageExternalization_SurvivesFinalizeRewrite(t *testing.T) { t.Parallel()

if _, err := exec.LookPath("sqlite3"); err != nil { t.Skip("sqlite3 not installed; skipping cursor store.db capture test") }

env := NewFeatureBranchEnv(t) env.InitEntireWithAgent(agent.AgentNameCursor)

cursorProjectDir := t.TempDir() if resolved, err := filepath.EvalSymlinks(cursorProjectDir); err == nil { cursorProjectDir = resolved } chatsDir := t.TempDir() env.ExtraEnv = append(env.ExtraEnv, "ENTIRE_TEST_CURSOR_PROJECT_DIR="+cursorProjectDir, "ENTIRE_TEST_CURSOR_CHATS_DIR="+chatsDir, )

const conversationID = "cursor-finalize-wipe" transcriptDir := filepath.Join(cursorProjectDir, conversationID) require.NoError(t, os.MkdirAll(transcriptDir, 0o755)) transcriptPath := filepath.Join(transcriptDir, conversationID+".jsonl") // v1: what condensation stores at the mid-turn commit. require.NoError(t, os.WriteFile(transcriptPath, []byte({"type":"user","text":"look at this screenshot and add a feature"}+"\n"), 0o600))

img := append([]byte("\x89PNG\r\n\x1a\n"), []byte(strings.Repeat("cursor-finalize-image-payload-", 8))...) storeDBPath := filepath.Join(chatsDir, "workspace-hash", conversationID, "store.db") require.NoError(t, os.MkdirAll(filepath.Dir(storeDBPath), 0o755)) buildCursorStoreDB(t, storeDBPath, map[string][]byte{"img-blob": img})

// Mid-turn commit while the session is ACTIVE: condensation creates the // checkpoint + captures the sidecar image, and PostCommit records it in // TurnCheckpointIDs so the later stop finalize runs over it. AsAgent takes the // no-TTY active-session fast path (a human mid-turn commit path differs). env.WriteFile("feature.go", "package main\n// new feature\n") env.GitCommitWithShadowHooksAsAgent("Add feature", "feature.go")

cpID := env.TryGetLatestCheckpointID() require.NotEmpty(t, cpID, "expected a condensed checkpoint after the mid-turn commit") sessionPath := ShardedCheckpointPath(cpID) + "/0/" _, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile) require.True(t, ok, "PRECONDITION: condensation should have captured the sidecar image")

// Grow the transcript so the finalized full transcript differs from what // condensation stored -> replaceTranscript reports rewrote==true, the exact // condition under which finalize rewrites (and previously wiped) the assets. require.NoError(t, os.WriteFile(transcriptPath, []byte({"type":"user","text":"look at this screenshot and add a feature"}+"\n"+ {"type":"assistant","text":"added the feature"}+"\n"), 0o600))

runCursorHook(t, env, cursorProjectDir, "stop", map[string]any{ "conversation_id": conversationID, "transcript_path": transcriptPath, "model": "cursor-default", "loop_count": 1, })

// Regression assertion: the image asset must STILL be present after finalize. manifest, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile) require.True(t, ok, "assets/manifest.json missing after finalize — sidecar image was wiped") var manifestDoc struct { Assets []struct { Name string json:"name" } json:"assets" } require.NoError(t, json.Unmarshal([]byte(manifest), &manifestDoc)) require.Len(t, manifestDoc.Assets, 1, "expected the captured image to survive finalize") blob, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsDir+manifestDoc.Assets[0].Name) require.True(t, ok, "asset blob missing after finalize") require.Equal(t, string(img), blob, "asset bytes changed after finalize") }

// TestCursorImageExternalization_PreservesImagesOnFinalizeCaptureMiss guards the // best-effort edge: condensation captures a Cursor image, but the sidecar // re-capture at finalize yields nothing (e.g. sqlite3 locked/timed out, or — as // simulated here — the store.db is momentarily gone). A rewriting finalize must // then PRESERVE the images condensation stored rather than clearing the assets/ // folder for the now-empty asset set. func TestCursorImageExternalization_PreservesImagesOnFinalizeCaptureMiss(t *testing.T) { t.Parallel()

if _, err := exec.LookPath("sqlite3"); err != nil { t.Skip("sqlite3 not installed; skipping cursor store.db capture test") }

env := NewFeatureBranchEnv(t) env.InitEntireWithAgent(agent.AgentNameCursor)

const conversationID = "cursor-finalize-miss" transcriptDir := filepath.Join(cursorProjectDir, conversationID) require.NoError(t, os.MkdirAll(transcriptDir, 0o755)) transcriptPath := filepath.Join(transcriptDir, conversationID+".jsonl") require.NoError(t, os.WriteFile(transcriptPath, []byte({"type":"user","text":"look at this screenshot and add a feature"}+"\n"), 0o600))

img := append([]byte("\x89PNG\r\n\x1a\n"), []byte(strings.Repeat("cursor-preserve-image-payload-", 8))...) storeDBPath := filepath.Join(chatsDir, "workspace-hash", conversationID, "store.db") require.NoError(t, os.MkdirAll(filepath.Dir(storeDBPath), 0o755)) buildCursorStoreDB(t, storeDBPath, map[string][]byte{"img-blob": img})

// Mid-turn commit: condensation captures the image into the checkpoint. env.WriteFile("feature.go", "package main\n// new feature\n") env.GitCommitWithShadowHooksAsAgent("Add feature", "feature.go")

// Grow the transcript so finalize rewrites (rewrote=true), AND remove the // store.db so the finalize re-capture yields nothing — the transient-miss case. require.NoError(t, os.WriteFile(transcriptPath, []byte({"type":"user","text":"look at this screenshot and add a feature"}+"\n"+ {"type":"assistant","text":"added the feature"}+"\n"), 0o600)) require.NoError(t, os.Remove(storeDBPath))

// The image captured at condensation must survive the finalize rewrite even // though the re-capture found nothing. manifest, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile) require.True(t, ok, "assets/manifest.json missing after finalize — sidecar image was wiped on a capture miss") var manifestDoc struct { Assets []struct { Name string json:"name" } json:"assets" } require.NoError(t, json.Unmarshal([]byte(manifest), &manifestDoc)) require.Len(t, manifestDoc.Assets, 1, "expected the captured image to survive a finalize capture miss") blob, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsDir+manifestDoc.Assets[0].Name) require.True(t, ok, "asset blob missing after finalize") require.Equal(t, string(img), blob, "asset bytes changed after finalize") }

// buildCursorStoreDB writes a Cursor-style store.db with a blobs(id, data) table // populated from the given blobs, by shelling out to sqlite3. func buildCursorStoreDB(t *testing.T, path string, blobs map[string][]byte) { t.Helper() var sb strings.Builder sb.WriteString("CREATE TABLE blobs(id TEXT PRIMARY KEY, data BLOB);\n") for id, data := range blobs { sb.WriteString("INSERT INTO blobs(id,data) VALUES('" + id + "', x'" + hex.EncodeToString(data) + "');\n") } cmd := exec.CommandContext(context.Background(), "sqlite3", path, sb.String()) out, err := cmd.CombinedOutput() require.NoErrorf(t, err, "build store.db: %s", out) }


Acmd/entire/cli/integration\_test/cursor\_image\_externalize\_test.go+321

//go:build integration

package integration

import (
    "context"
    "encoding/base64"
    "os"
    "path/filepath"
    "strings"
    "testing"

// TestImageExternalization_FullHookFlow is the real end-to-end proof: it drives
// the actual entire hook binary (mid-turn commit -> condensation, then Stop ->
// finalize) on a Claude Code session whose transcript embeds an inline base64
// image, with externalization enabled via settings.local.json (also exercising
// the local-settings-merge fix). It then inspects the real entire/checkpoints/v1
// ref and confirms:
//   - full.jsonl carries the placeholder, not the raw base64 (survives finalize)
//   - the asset blob + manifest.json were written and decode to the exact image
//   - ReadSessionContent (the restore path) reinjects the image byte-exactly
func TestImageExternalization_FullHookFlow(t *testing.T) {
    // Uses settings/env that must be stable across the hook subprocesses; no t.Parallel.
    env := NewFeatureBranchEnv(t)

// Enable externalization via the gitignored local settings file (the natural
    // rollout opt-in, and the path the merge fix restored).
    localSettings := filepath.Join(env.RepoDir, ".entire", "settings.local.json")
    if err := os.WriteFile(localSettings, []byte(`{"redaction":{"externalize_images":true}}`), 0o644); err != nil {
        t.Fatalf("write settings.local.json: %v", err)
    }

// A real, minimal PNG (valid magic bytes), padded so its base64 clears the
    // externalization length threshold.
    imgBytes := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("entire-real-e2e-image-payload-", 4))
    b64 := base64.StdEncoding.EncodeToString(imgBytes)

session := env.NewSession()

// Author a Claude Code transcript: prompt, a user turn with an inline image,
    // a file-writing tool use (so the commit has attributable content), result.
    transcript := strings.Join([]string{
        `{"uuid":"u1","type":"user","message":{"role":"user","content":"add feature and look at this"},"timestamp":"2026-01-01T00:00:00Z"}`,
        `{"uuid":"u2","type":"user","message":{"role":"user","content":[{"type":"text","text":"screenshot"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + b64 + `"}}]},"timestamp":"2026-01-01T00:00:01Z"}`,
        `{"uuid":"a1","type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Write","input":{"file_path":"feature.go","content":"package main\n"}}]},"timestamp":"2026-01-01T00:00:02Z"}`,
        `{"uuid":"u3","type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"Success"}]},"timestamp":"2026-01-01T00:00:03Z"}`,
        `{"uuid":"a2","type":"assistant","message":{"content":[{"type":"text","text":"done"}]},"timestamp":"2026-01-01T00:00:04Z"}`,
    }, "\n") + "\n"
    if err := os.WriteFile(session.TranscriptPath, []byte(transcript), 0o644); err != nil {
        t.Fatalf("write transcript: %v", err)
    }

if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(session.ID, "add feature and look at this", session.TranscriptPath); err != nil {
        t.Fatalf("UserPromptSubmit: %v", err)
    }

// Mid-turn commit -> post-commit condensation externalizes.
    env.WriteFile("feature.go", "package main\n")
    env.GitCommitWithShadowHooks("add feature", "feature.go")

// Stop -> finalize rewrites each turn checkpoint with the full transcript. This
    // is where the (fixed) re-inlining bug lived: assert externalization survives it.
    if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
        t.Fatalf("Stop: %v", err)
    }

if !env.BranchExists(paths.MetadataBranchName) {
        t.Fatal("entire/checkpoints/v1 should exist after condensation")
    }
    cpID := env.GetLatestCheckpointIDFromHistory()
    if cpID == "" {
        t.Fatal("no checkpoint id found in history")
    }
    sessionPath := ShardedCheckpointPath(cpID) + "/0/"

// full.jsonl: placeholder present, raw base64 gone (externalized, and it stuck
    // through finalize).
    full, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.TranscriptFileName)
    if !ok {
        t.Fatalf("full.jsonl missing at %s", sessionPath)
    }
    if strings.Contains(full, b64) {
        t.Error("stored full.jsonl still contains the raw base64 image (externalization did not persist)")
    }
    if !strings.Contains(full, "entire-asset:assets/") {
        t.Error("stored full.jsonl has no image placeholder")
    }

// manifest.json written; the asset blob decodes to the exact original image.
    manifest, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile)
    if !ok {
        t.Fatal("assets/manifest.json missing")
    }
    if !strings.Contains(manifest, `"media_type": "image/png"`) {
        t.Errorf("manifest missing png entry: %s", manifest)
    }

// Restore path: ReadSessionContent reinjects the image byte-exactly.
    repo, err := gitrepo.OpenPath(env.RepoDir)
    if err != nil {
        t.Fatalf("open repo: %v", err)
    }
    defer repo.Close()
    stores, err := checkpoint.Open(context.Background(), repo, checkpoint.OpenOptions{})
    if err != nil {
        t.Fatalf("open stores: %v", err)
    }
    checkpointID, err := id.NewCheckpointID(cpID)
    if err != nil {
        t.Fatalf("parse checkpoint id %q: %v", cpID, err)
    }
    content, err := stores.Persistent.ReadSessionContent(context.Background(), checkpointID, 0)
    if err != nil {
        t.Fatalf("ReadSessionContent: %v", err)
    }
    if strings.Contains(string(content.Transcript), "entire-asset:assets/") {
        t.Error("restored transcript still has a placeholder (reinjection failed)")
    }
    if !strings.Contains(string(content.Transcript), b64) {
        t.Error("restored transcript is missing the reinjected base64 image")
    }
}

// TestImageExternalization_FinalizeWithFlagOffPreservesAssets guards the
// config-drift case: externalization is ON at condensation (placeholders +
// assets stored) but OFF at finalize (env override not inherited by the hook
// process, or settings toggled mid-session). Extraction then doesn't run at
// finalize and finalizeAssets is empty — that must mean "didn't run", not
// "no images": the previously-stored asset blobs must survive the rewrite
// (the re-inlined base64 in the finalized transcript is destroyed by
// redaction, so clearing the assets would lose the images permanently).
func TestImageExternalization_FinalizeWithFlagOffPreservesAssets(t *testing.T) {
    env := NewFeatureBranchEnv(t)

imgBytes := []byte("\x89PNG\r\n\x1a\n" + strings.Repeat("entire-flag-drift-image-payload-", 4))
    b64 := base64.StdEncoding.EncodeToString(imgBytes)

session := env.NewSession()
    transcript := strings.Join([]string{
        `{"uuid":"u1","type":"user","message":{"role":"user","content":"add feature and look at this"},"timestamp":"2026-01-01T00:00:00Z"}`,
        `{"uuid":"u2","type":"user","message":{"role":"user","content":[{"type":"text","text":"screenshot"},{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + b64 + `"}}]},"timestamp":"2026-01-01T00:00:01Z"}`,
        `{"uuid":"a1","type":"assistant","message":{"content":[{"type":"tool_use","id":"toolu_1","name":"Write","input":{"file_path":"feature.go","content":"package main\n"}}]},"timestamp":"2026-01-01T00:00:02Z"}`,
        `{"uuid":"u3","type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"Success"}]},"timestamp":"2026-01-01T00:00:03Z"}`,
        `{"uuid":"a2","type":"assistant","message":{"content":[{"type":"text","text":"done"}]},"timestamp":"2026-01-01T00:00:04Z"}`,
    }, "\n") + "\n"
    if err := os.WriteFile(session.TranscriptPath, []byte(transcript), 0o644); err != nil {
        t.Fatalf("write transcript: %v", err)
    }
    if err := env.SimulateUserPromptSubmitWithPromptAndTranscriptPath(session.ID, "add feature and look at this", session.TranscriptPath); err != nil {
        t.Fatalf("UserPromptSubmit: %v", err)
    }

// Mid-turn commit with the flag ON: condensation stores placeholder + asset.
    env.WriteFile("feature.go", "package main\n")
    env.GitCommitWithShadowHooks("add feature", "feature.go")

cpID := env.GetLatestCheckpointIDFromHistory()
    if cpID == "" {
        t.Fatal("no checkpoint id found in history")
    }
    sessionPath := ShardedCheckpointPath(cpID) + "/0/"
    manifest, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile)
    if !ok {
        t.Fatal("PRECONDITION: condensation should have stored assets/manifest.json")
    }

// Toggle the flag OFF before the stop finalize.
    if err := os.Remove(localSettings); err != nil {
        t.Fatalf("remove settings.local.json: %v", err)
    }
    if err := env.SimulateStop(session.ID, session.TranscriptPath); err != nil {
        t.Fatalf("Stop: %v", err)
    }

// The stored assets must survive the finalize rewrite.
    manifestAfter, ok := env.ReadFileFromBranch(paths.MetadataBranchName, sessionPath+paths.AssetsManifestFile)
    if !ok {
        t.Fatal("assets/manifest.json was cleared by a finalize that ran without externalization")
    }
    if manifestAfter != manifest {
        t.Errorf("manifest changed across a flag-off finalize:\nbefore: %s\nafter: %s", manifest, manifestAfter)
    }
}

Acmd/entire/cli/integration_test/image_externalize_test.go+194

1102 unmodified lines

1103
1104
1105
1106
1107
1108
1109
1110
1111

1102 unmodified lines

"ENTIRE_TEST_OPENCODE_PROJECT_DIR="+env.OpenCodeProjectDir,
        "ENTIRE_TEST_OPENCODE_MOCK_EXPORT=1",
    )
    // Propagate per-test overrides (e.g. agent project/store dirs) to hook
    // subprocesses. Empty for tests that don't set ExtraEnv.
    envVars = append(envVars, env.ExtraEnv...)
    envVars = append(envVars, env.checkpointStoreEnv()...)
    return append(envVars, extra...)
}

Mcmd/entire/cli/integration_test/testenv.go+3

35 unmodified lines

36
37
38
39
40
41
42
43
44
45
46
47
48

35 unmodified lines

CheckpointFileName        = "checkpoint.json"
    ContentHashFileName       = "content_hash.txt"
    SettingsFileName          = "settings.json"

// AssetsDir is the per-session subfolder holding externalized transcript
    // assets (e.g. images); AssetsManifestFile indexes them. AssetsDirName is the
    // bare tree-entry name (no trailing slash) used when walking git trees.
    AssetsDirName      = "assets"
    AssetsDir          = "assets/"
    AssetsManifestFile = "assets/manifest.json"
)

// MetadataBranchName is the orphan branch used by manual-commit strategy to store metadata

Mcmd/entire/cli/paths/paths.go+7

250 unmodified lines

251
252
253
254
255
256
257
258
259
260
261
882 unmodified lines

1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
188 unmodified lines

1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365

250 unmodified lines

// OpenAIPrivacyFilter is the optional 8th redaction layer (opt-in).
    // See docs/security-and-privacy.md.
    OpenAIPrivacyFilter *OPFSettings `json:"openai_privacy_filter,omitempty"`

// ExternalizeImages opts into lifting inline base64 images out of transcripts
    // into the checkpoint's assets/ store (off by default). Restore re-injects
    // them regardless of this flag.
    ExternalizeImages bool `json:"externalize_images,omitempty"`
}

// PIISettings configures PII detection categories.
882 unmodified lines

return err
        }
    }
    if extRaw, ok := raw["externalize_images"]; ok {
        var v bool
        if err := json.Unmarshal(extRaw, &v); err != nil {
            return fmt.Errorf("parsing redaction.externalize_images: %w", err)
        }
        dst.ExternalizeImages = v
    }
    return nil
}

188 unmodified lines

return settings.IsSummarizeEnabled()
}

// IsImageExternalizationEnabled reports whether inline base64 images should be
// lifted out of transcripts into the checkpoint asset store. Opt-in via
// redaction.externalize_images, or the ENTIRE_EXTERNALIZE_IMAGES=1 env override
// (handy for testing/rollout). Off by default.
func IsImageExternalizationEnabled(ctx context.Context) bool {
    if v := os.Getenv("ENTIRE_EXTERNALIZE_IMAGES"); v == "1" || v == "true" {
        return true
    }
    s, err := Load(ctx)
    if err != nil {
        return false
    }
    return s.Redaction != nil && s.Redaction.ExternalizeImages
}

// IsSummarizeEnabled checks if auto-summarize is enabled in this settings instance.
func (s *EntireSettings) IsSummarizeEnabled() bool {
    if s.StrategyOptions == nil {

Mcmd/entire/cli/settings/settings.go+27

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

package settings

import (
    "context"
    "testing"
)

// These tests use setupSettingsDir (t.Chdir) and t.Setenv, both process-global,
// so they cannot run in parallel.

func TestIsImageExternalizationEnabled_DefaultsFalse(t *testing.T) {
    setupSettingsDir(t, `{"enabled": true}`, "")
    if IsImageExternalizationEnabled(context.Background()) {
        t.Error("image externalization should be off by default")
    }
}

func TestIsImageExternalizationEnabled_FileEnabled(t *testing.T) {
    setupSettingsDir(t, `{"enabled": true, "redaction": {"externalize_images": true}}`, "")
    if !IsImageExternalizationEnabled(context.Background()) {
        t.Error("redaction.externalize_images: true should enable externalization")
    }
}

func TestIsImageExternalizationEnabled_EnvOverride(t *testing.T) {
    setupSettingsDir(t, `{"enabled": true}`, "")
    t.Setenv("ENTIRE_EXTERNALIZE_IMAGES", "1")
    if !IsImageExternalizationEnabled(context.Background()) {
        t.Error("ENTIRE_EXTERNALIZE_IMAGES=1 should enable externalization regardless of settings")
    }
}

func TestIsImageExternalizationEnabled_LocalFileEnables(t *testing.T) {
    // The gitignored settings.local.json is the natural place to opt into a
    // rollout feature; the merge path must honor it.
    setupSettingsDir(t, `{"enabled": true}`, `{"redaction": {"externalize_images": true}}`)
    if !IsImageExternalizationEnabled(context.Background()) {
        t.Error("externalize_images in settings.local.json must enable externalization")
    }
}

func TestIsImageExternalizationEnabled_LocalFileDisablesBaseEnable(t *testing.T) {
    // A per-machine kill switch: local:false must override base:true.
    setupSettingsDir(t,
        `{"enabled": true, "redaction": {"externalize_images": true}}`,
        `{"redaction": {"externalize_images": false}}`)
    if IsImageExternalizationEnabled(context.Background()) {
        t.Error("local externalize_images:false must override a base value of true")
    }
}

// TestRedactionSettings_ExternalizeImagesJSONTag guards the JSON field name.
// LoadFromBytes uses DisallowUnknownFields, so a wrong tag fails to parse.
func TestRedactionSettings_ExternalizeImagesJSONTag(t *testing.T) {
    t.Parallel()
    s, err := LoadFromBytes([]byte(`{"enabled": true, "redaction": {"externalize_images": true}}`))
    if err != nil {
        t.Fatalf("LoadFromBytes() error = %v", err)
    }
    if s.Redaction == nil || !s.Redaction.ExternalizeImages {
        t.Errorf("externalize_images did not parse into RedactionSettings.ExternalizeImages")
    }
}

Acmd/entire/cli/settings/settings_images_test.go+63

package strategy

import ( "context" "encoding/base64" "strings" "testing"

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

// These tests exercise the opt-in image-externalization step in the condensation // pipeline. They use t.Chdir / t.Setenv (process-global) to control the settings // flag, so they cannot run in parallel.

// claudeImageLine returns a Claude Code user line embedding one inline base64 // image, plus the base64 string for assertions. func claudeImageLine(t *testing.T, payload string) (line, b64 string) { t.Helper() b64 = base64.StdEncoding.EncodeToString([]byte(payload)) line = {"type":"user","message":{"role":"user","content":[ +
{"type":"text","text":"look"}, +
{"type":"image","source":{"type":"base64","media_type":"image/png","data":" + b64 + "}} +
]}} return line, b64 }

func TestExternalizeSessionImages_DisabledIsNoOp(t *testing.T) { t.Chdir(t.TempDir()) // isolate settings; externalization defaults off line, b64 := claudeImageLine(t, "disabled-noop-bytes-padded-long-enough-to-externalize") raw := []byte(line + "\n") state := &SessionState{SessionID: "s1", AgentType: agent.AgentTypeClaudeCode}

rewritten, assets := externalizeSessionImages(context.Background(), context.Background(), state, raw) if assets != nil { t.Errorf("expected no assets when flag off, got %d", len(assets)) } if string(rewritten) != string(raw) { t.Error("transcript must be unchanged when externalization is off") } if !strings.Contains(string(rewritten), b64) { t.Error("base64 image should still be inline when externalization is off") } }

func TestExternalizeSessionImages_EnabledExtracts(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("ENTIRE_EXTERNALIZE_IMAGES", "1") line, b64 := claudeImageLine(t, "enabled-extract-bytes-padded-long-enough-to-externalize") raw := []byte(line + "\n") state := &SessionState{SessionID: "s2", AgentType: agent.AgentTypeClaudeCode}

rewritten, assets := externalizeSessionImages(context.Background(), context.Background(), state, raw) if len(assets) != 1 { t.Fatalf("expected 1 asset when flag on, got %d", len(assets)) } if strings.Contains(string(rewritten), b64) { t.Error("base64 image should be externalized out of the transcript") } if !strings.Contains(string(rewritten), "entire-asset:assets/") { t.Error("transcript should carry a placeholder after externalization") } // The caller's raw transcript must be left untouched (growth-baseline / result). if !strings.Contains(string(raw), b64) { t.Error("the input transcript must not be mutated by externalization") } }

func TestExternalizeSessionImages_NonImageAgentIsNoOp(t *testing.T) { t.Chdir(t.TempDir()) t.Setenv("ENTIRE_EXTERNALIZE_IMAGES", "1") // on, but agent has no codec line, b64 := claudeImageLine(t, "codex-noop-bytes-padded-long-enough-to-externalize") raw := []byte(line + "\n") state := &SessionState{SessionID: "s3", AgentType: types.AgentType("Codex")}

rewritten, assets := externalizeSessionImages(context.Background(), context.Background(), state, raw) if assets != nil { t.Errorf("agent with no image codec should extract nothing, got %d assets", len(assets)) } if string(rewritten) != string(raw) || !strings.Contains(string(rewritten), b64) { t.Error("transcript must pass through unchanged for a no-codec agent") } }

// TestExtractThenRedact_ImageExternalizedSecretRedacted proves the mandatory // ordering: on a line carrying BOTH a base64 image and a high-entropy secret, // extracting first lifts the image into an asset (placeholder left behind), the // redaction pass then strips the secret while leaving the low-entropy // placeholder intact, and reinjection restores the exact image bytes. The stored // (post-extract, post-redact) transcript therefore contains neither the raw // image blob nor the secret. func TestExtractThenRedact_ImageExternalizedSecretRedacted(t *testing.T) { t.Parallel() secret := "aB3xK9mQ7pL2wR8tY4vN6cF1gH5jD0sZeW7uI2oP" b64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nordering-fixture-bytes-padded-long-enough-to-externalize\x00\x01\x02")) raw := []byte({"type":"user","message":{"role":"user","content":[ +
{"type":"text","text":"my token + secret + ok"}, +
{"type":"image","source":{"type":"base64","media_type":"image/png","data":" + b64 + "}} +
]}} + "\n")

codec := imageextract.CodecFor(agent.AgentTypeClaudeCode) if codec == nil { t.Fatal("expected a Claude Code image codec") }

// Step 1: extract images (before redaction). rewritten, assets, err := codec.ExtractImages(raw) if err != nil { t.Fatalf("ExtractImages() error = %v", err) } if len(assets) != 1 { t.Fatalf("expected 1 asset, got %d", len(assets)) } if strings.Contains(string(rewritten), b64) { t.Error("image base64 should be gone after extraction") } if !strings.Contains(string(rewritten), secret) { t.Error("secret must still be present pre-redaction") }

// Step 2: redact the placeholder-bearing transcript. redacted, err := redactSessionJSONLBytes(context.Background(), rewritten) if err != nil { t.Fatalf("redactSessionJSONLBytes() error = %v", err) } stored := string(redacted.Bytes()) if strings.Contains(stored, secret) { t.Error("secret must be redacted out of the stored transcript") } if !strings.Contains(stored, "REDACTED") { t.Error("expected a REDACTED marker where the secret was") } if !strings.Contains(stored, "entire-asset:assets/") { t.Error("placeholder must survive redaction (low entropy)") } if strings.Contains(stored, b64) { t.Error("stored transcript must not contain the raw image blob") }

// Step 3: reinject restores the exact image bytes. lookup := func(name string) (imageextract.Asset, bool) { for _, a := range assets { if a.Name == name { return a, true } } return imageextract.Asset{}, false } restored, err := codec.ReinjectImages(redacted.Bytes(), lookup) if err != nil { t.Fatalf("ReinjectImages() error = %v", err) } final := string(restored) if !strings.Contains(final, b64) { t.Error("image should be reinjected on restore") } if strings.Contains(final, "entire-asset:assets/") { t.Error("no placeholder should remain after reinjection") } if strings.Contains(final, secret) { t.Error("secret must stay redacted after reinjection") } }


Acmd/entire/cli/strategy/condense\_images\_test.go+165

25 unmodified lines

26 27 28 29 30 31 32 85 unmodified lines

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 98 unmodified lines

303 304 305 224 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 40 unmodified lines

365 366 367 368 369 370 371

25 unmodified lines

"github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/summarize" "github.com/entireio/cli/cmd/entire/cli/transcript" "github.com/entireio/cli/cmd/entire/cli/transcript/imageextract" "github.com/entireio/cli/perf" "github.com/entireio/cli/redact"

85 unmodified lines

return redact.JSONLBytes(b) }

// extractSessionImages lifts inline base64 images out of a transcript into // externalized assets via the per-agent image codec, returning the rewritten // (placeholder-bearing) transcript. Agents with no codec, or transcripts with no // externalizable images, pass through unchanged. Injectable for tests. var extractSessionImages = func(agentType types.AgentType, transcript []byte) ([]byte, []cpkg.TranscriptAsset, error) { codec := imageextract.CodecFor(agentType) if codec == nil { return transcript, nil, nil } rewritten, assets, err := codec.ExtractImages(transcript) if err != nil { return transcript, nil, fmt.Errorf("extract images: %w", err) } if len(assets) == 0 { return transcript, nil, nil } out := make([]cpkg.TranscriptAsset, len(assets)) for i, a := range assets { out[i] = cpkg.TranscriptAsset{Name: a.Name, MediaType: a.MediaType, Data: a.Data} } return rewritten, out, nil }

// externalizeSessionImages runs the opt-in image-externalization step over a // session transcript before redaction. When enabled it returns the rewritten // (placeholder-bearing) transcript plus the extracted assets; when disabled, // unsupported for the agent, or on error it returns the transcript unchanged with // nil assets (the checkpoint then stores the inline transcript). // // It deliberately does NOT mutate the caller's transcript: the raw transcript is // still needed for CondenseResult.Transcript (trail titles) and, critically, as // the CheckpointTranscriptSize growth baseline, which is compared against the raw // inline shadow-branch blob — feeding it the shrunken externalized size would // report spurious growth on every subsequent commit. func externalizeSessionImages(ctx, logCtx context.Context, state *SessionState, transcript []byte) ([]byte, []cpkg.TranscriptAsset) { if !settings.IsImageExternalizationEnabled(ctx) { return transcript, nil } rewritten, assets, err := extractSessionImages(state.AgentType, transcript) if err != nil { logging.Warn(logCtx, "image externalization failed; leaving transcript inline", slog.String("session_id", state.SessionID), slog.String("error", err.Error())) return transcript, nil } return rewritten, assets }

// sidecarSessionImages captures images an agent stores OUTSIDE the transcript // (e.g. Cursor's per-session SQLite blob store) as checkpoint assets, so they are // preserved with the session even though they never appear in full.jsonl. Unlike // externalizeSessionImages there is no transcript placeholder and no round trip: // these assets are preserve/view-only (the agent reads its own store on restore). // // Gated on the same opt-in flag. Best-effort: agents without the capability, or // any capture error, yield no assets (the checkpoint is written without them). func sidecarSessionImages(ctx, logCtx context.Context, ag agent.Agent, state *SessionState) []cpkg.TranscriptAsset { if !settings.IsImageExternalizationEnabled(ctx) { return nil } provider, ok := agent.AsSidecarImageProvider(ag) if !ok { return nil } assets, err := provider.SidecarImages(ctx, state.TranscriptPath) if err != nil { logging.Warn(logCtx, "sidecar image capture failed; checkpoint stored without them", slog.String("session_id", state.SessionID), slog.String("error", err.Error())) return nil } if len(assets) == 0 { return nil } out := make([]cpkg.TranscriptAsset, len(assets)) for i, a := range assets { out[i] = cpkg.TranscriptAsset{Name: a.Name, MediaType: a.MediaType, Data: a.Data} } return out }

// checkpointStepCount returns the number of user prompts attributed to the // checkpoint being written: the turns counted since the current window's base. // The base is re-anchored (deferred) the next time a turn is counted after a 98 unmodified lines

filterFilesTouched(sessionData, committedFiles, state)

redactedTranscript, redactDuration := redactOrDrop(logCtx, sessionData.Transcript, state.SessionID, checkpointID) // Externalize inline images BEFORE redaction: base64 is high-entropy and // redaction would otherwise flag/destroy it. Opt-in; a no-codec agent or a // transcript with no externalizable images is a no-op. sessionData.Transcript // is left as the raw transcript (used for the result / growth baseline); only // the redacted, externalized copy is stored. externalizedTranscript, extractedAssets := externalizeSessionImages(ctx, logCtx, state, sessionData.Transcript)

redactedTranscript, redactDuration := redactOrDrop(logCtx, externalizedTranscript, state.SessionID, checkpointID) if skipped := skipIfPostRedactionEmpty(logCtx, redactedTranscript, sessionData, state, checkpointID); skipped != nil { return skipped, nil }

// Capture agent sidecar images (e.g. Cursor's SQLite store) after the skip // check, so the sqlite3 shell-out is avoided when the checkpoint is discarded. extractedAssets = append(extractedAssets, sidecarSessionImages(ctx, logCtx, ag, state)...)

store, err := s.getPersistentStore(ctx, repo) if err != nil { return nil, err 40 unmodified lines

Strategy: StrategyNameManualCommit, Branch: branchName, Transcript: redactedTranscript, Assets: extractedAssets, Prompts: sessionData.Prompts, FilesTouched: sessionData.FilesTouched, CheckpointsCount: checkpointStepCount(state),


Mcmd/entire/cli/strategy/manual\_commit\_condensation.go+95/-1

2844 unmodified lines

2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 32 unmodified lines

2922 2923 2924 2886 2887 2888 2889 2890 2891 2892 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936

2844 unmodified lines

// Run the 7-layer pipeline over the transcript — OPF runs later in // the pre-push rewrite path, which re-redacts these 7-layer blobs // and produces 8-layer commits before the push goes out. // Externalize inline images BEFORE redaction, mirroring CondenseSession, so the // finalized (authoritative, full-session) transcript keeps its placeholders and // matching assets instead of re-inlining what condensation lifted out. Opt-in; // a no-codec agent or a transcript with no images is a no-op. var finalizeAssets []checkpoint.TranscriptAsset // Whether externalization actually RAN at finalize. When it did not (flag // off here even though it may have been on at condensation — e.g. an // ENTIRE_EXTERNALIZE_IMAGES env override not inherited by the hook // process, or settings toggled mid-session), an empty finalizeAssets means // "extraction didn't run", NOT "the transcript has no images" — clearing // the previously-stored assets would permanently lose them (the re-inlined // base64 is destroyed by redaction below). externalizationRan := false if settings.IsImageExternalizationEnabled(ctx) { rewritten, assets, exErr := extractSessionImages(state.AgentType, fullTranscript) if exErr != nil { logging.Warn(logCtx, "finalize: image externalization failed; leaving transcript inline", slog.String("session_id", state.SessionID), slog.String("error", exErr.Error()), ) } else { fullTranscript = rewritten finalizeAssets = assets externalizationRan = true } } // Re-capture sidecar images (e.g. Cursor's SQLite store) so a finalize that // rewrites the transcript re-writes them too. When the full transcript differs // from the stored (mid-turn) one, writeAssets clears the whole assets/ folder // and re-writes only these assets — omitting the sidecar images here would drop // what CondenseSession captured. Content-hash names make this idempotent with // condensation's write; when the transcript is unchanged, writeAssets is not // called and condensation's assets are left intact. finalizeAssets = append(finalizeAssets, sidecarSessionImages(ctx, logCtx, ag, state)...) // Sidecar capture is best-effort: a transient miss here (sqlite3 locked/timed // out) yields no assets. For a sidecar-capable agent, preserve the assets a // prior condensation stored rather than letting an empty set clear them. _, sidecarCapable := agent.AsSidecarImageProvider(ag)

_, redactSpan := perf.Start(logCtx, "redact_transcript") redactedTranscript, redactErr := redact.JSONLBytes(fullTranscript) redactSpan.End() 32 unmodified lines

}

updateOpts := checkpoint.UpdateOptions{ CheckpointID: cpID, SessionID: state.SessionID, Transcript: redactedTranscript, Prompts: prompts, Agent: state.AgentType, SkillEvents: skillEvents, PrecomputedBlobs: precomputed, CheckpointID: cpID, SessionID: state.SessionID, Transcript: redactedTranscript, Assets: finalizeAssets, PreserveAssetsWhenEmpty: sidecarCapable || !externalizationRan, Prompts: prompts, Agent: state.AgentType, SkillEvents: skillEvents, PrecomputedBlobs: precomputed, }

updateErr := store.Write(ctx, checkpoint.SessionTranscript(updateOpts))


Mcmd/entire/cli/strategy/manual\_commit\_hooks.go+48/-7

30 unmodified lines

31 32 33 34 35 36 37 38 39 40 41 524 unmodified lines

566 567 568 569 570 571 572 573 574 575 576 577 578 63 unmodified lines

642 643 644 645 646 647 648 649 650 651 652 653 654

30 unmodified lines

"github.com/go-git/go-git/v6/storage" )

// assetsDirName mirrors paths.AssetsDirName, captured at package scope so the // OPF tree walkers can reference it even where a local variable named paths // shadows the paths package (collectTreeBlobs). const assetsDirName = paths.AssetsDirName

// V1DivergedError: local entire/checkpoints/v1 has commits that aren't // ancestors of the remote tip (force-push or another machine pushed). // Rewriting under divergence would silently rebase rejected work, so 524 unmodified lines

for _, e := range tree.Entries { switch e.Mode { case filemode.Dir: // Externalized image assets are opaque binary lifted out of the // (already redaction-skipped) transcript; byte-redacting them would // only corrupt the images. Skip the whole subtree — symmetrically with // rebuildTreeWithCachedRedaction, which preserves it verbatim. if e.Name == assetsDirName { continue } subPath := e.Name if pathPrefix != "" { subPath = pathPrefix + "/" + e.Name 63 unmodified lines

for _, e := range tree.Entries { switch e.Mode { case filemode.Dir: // Preserve externalized image assets verbatim (see collectTreeBlobs): // they are opaque binary and carry no redactable text. Copying the // subtree hash keeps blobs byte-identical so restore still round-trips. if e.Name == assetsDirName { entries = append(entries, e) continue } subPath := e.Name if pathPrefix != "" { subPath = pathPrefix + "/" + e.Name


Mcmd/entire/cli/strategy/manual\_commit\_opf\_rewrite.go+19

566 unmodified lines

567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659

566 unmodified lines

require.False(t, collectedNames[paths.ContentHashFileName], "content_hash.txt must be excluded from collection (deferred path)") }

// The OPF rewrite must not touch externalized image assets: byte-redacting the // raw image blobs would corrupt them (breaking restore), and the fail-closed // rebuild would abort the push if they were collected but not redacted. Both // passes skip the assets/ subtree, preserving it verbatim. func TestOPFRewrite_PreservesAssetsSubtreeVerbatim(t *testing.T) { configureFakeOPF(t, &fakeOPFForRewrite{}) tempDir := t.TempDir() testutil.InitRepo(t, tempDir) repo, err := git.PlainOpen(tempDir) require.NoError(t, err)

writeBlob := func(content []byte) plumbing.Hash { obj := repo.Storer.NewEncodedObject() obj.SetType(plumbing.BlobObject) w, err := obj.Writer() require.NoError(t, err) _, err = w.Write(content) require.NoError(t, err) require.NoError(t, w.Close()) hash, err := repo.Storer.SetEncodedObject(obj) require.NoError(t, err) return hash } writeTree := func(entries []object.TreeEntry) plumbing.Hash { tree := &object.Tree{Entries: entries} obj := repo.Storer.NewEncodedObject() require.NoError(t, tree.Encode(obj)) hash, err := repo.Storer.SetEncodedObject(obj) require.NoError(t, err) return hash }

// Raw binary image (high-entropy: byte redaction would mangle it) + manifest. imgBytes := []byte("\x89PNG\r\n\x1a\nPERSONABC-binary-image-bytes\x00\x01\x02\x03\xff\xfe") imgHash := writeBlob(imgBytes) manifestBytes := []byte({"version":1,"assets":[{"name":"img-abc.png"}]} + "\n") // Entries within a tree must be lexicographically ordered. assetsTreeHash := writeTree([]object.TreeEntry{ {Name: "img-abc.png", Mode: filemode.Regular, Hash: imgHash}, {Name: "manifest.json", Mode: filemode.Regular, Hash: writeBlob(manifestBytes)}, })

fullHash := writeBlob([]byte({"type":"text","text":"hi"} + "\n")) tree := &object.Tree{Entries: []object.TreeEntry{ {Name: paths.AssetsDirName, Mode: filemode.Dir, Hash: assetsTreeHash}, {Name: paths.ContentHashFileName, Mode: filemode.Regular, Hash: writeBlob([]byte("sha256:abcd"))}, {Name: paths.TranscriptFileName, Mode: filemode.Regular, Hash: fullHash}, }}

// Collect pass: assets/ contents are excluded; full.jsonl is collected. var blobs []redact.NamedBlob var blobPaths []string require.NoError(t, collectTreeBlobs(repo, tree, "", &blobs, &blobPaths)) collected := make(map[string]bool, len(blobs)) for _, b := range blobs { collected[b.Name] = true } require.True(t, collected[paths.TranscriptFileName], "full.jsonl must be collected for redaction") require.False(t, collected["img-abc.png"], "image asset must NOT be collected (would corrupt binary)") require.False(t, collected["manifest.json"], "asset manifest must NOT be collected")

// Rebuild pass: must not fail-closed, and must preserve the assets subtree // hash byte-for-byte (only full.jsonl gets redacted bytes from the map). redactedByPath := map[string][]byte{paths.TranscriptFileName: []byte({"type":"text","text":"redacted"} + "\n")} newTreeHash, err := rebuildTreeWithCachedRedaction(repo, tree, "", redactedByPath) require.NoError(t, err, "rebuild must not abort on the assets subtree")

newTree, err := repo.TreeObject(newTreeHash) require.NoError(t, err) var gotAssets plumbing.Hash for _, e := range newTree.Entries { if e.Name == paths.AssetsDirName { gotAssets = e.Hash } } require.Equal(t, assetsTreeHash, gotAssets, "assets subtree must be preserved verbatim")

// And the image blob inside is byte-identical. rebuiltAssets, err := repo.TreeObject(gotAssets) require.NoError(t, err) imgFile, err := rebuiltAssets.File("img-abc.png") require.NoError(t, err) gotImg, err := imgFile.Contents() require.NoError(t, err) require.Equal(t, string(imgBytes), gotImg, "image bytes must survive the OPF rewrite unchanged") }

// Fail-closed regression: when the OPF runtime fails and the breaker // trips, the rewrite must NOT CAS the ref. Otherwise the new commits // would carry Entire-OPF-Applied: true while their content is 7-layer


Mcmd/entire/cli/strategy/manual\_commit\_opf\_rewrite\_test.go+87

package imageextract

import (
    "encoding/base64"
    "strings"
    "testing"

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

// codexImageLine returns a real-format Codex user message embedding one inline
// image as a data-URI in an input_image content block (compact serialization,
// matching how the Codex rollout JSONL is written).
func codexImageLine(b64 string) string {
    return `{"type":"response_item","payload":{"type":"message","role":"user","content":[` +\
        `{"type":"input_text","text":"<image name=[Image #1]>"},` +\
        `{"type":"input_image","image_url":"data:image/png;base64,` + b64 + `"},` +\
        `{"type":"input_text","text":"</image>"}` +\
        `]}}`
}

// codexFunctionOutputLine embeds a data-URI inside function_call_output text,
// the way a screenshot/generated-image tool result appears in the rollout.
func codexFunctionOutputLine(b64 string) string {
    return `{"type":"response_item","payload":{"type":"function_call_output","call_id":"call_1",` +
        `"output":"here is the render: data:image/png;base64,` + b64 + ` done"}}`
}

func codexPNG(payload string) string {
    return base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\n" + payload + strings.Repeat("-codex-image-bytes", 3)))
}

func codexJPEG(payload string) string {
    return base64.StdEncoding.EncodeToString([]byte("\xFF\xD8\xFF" + payload + strings.Repeat("-codex-image-bytes", 3)))
}

// The core contract for Codex: extract then reinject reproduces the bytes exactly.
func TestCodexCodec_RoundTripByteExact(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    if c == nil {
        t.Fatal("expected a codec for Codex")
    }
    b64 := codexPNG("round-trip")
    orig := codexImageLine(b64) + "\n" +
        `{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}` + "\n"

rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if len(assets) != 1 {
        t.Fatalf("expected 1 asset, got %d", len(assets))
    }
    if assets[0].MediaType != mediaTypePNG {
        t.Errorf("media type = %q, want image/png", assets[0].MediaType)
    }
    if strings.Contains(string(rewritten), b64) {
        t.Error("base64 must be gone from the rewritten transcript")
    }
    // The data-URI prefix stays inline; only the base64 value became a placeholder.
    if !strings.Contains(string(rewritten), "data:image/png;base64,"+placeholderPrefix) {
        t.Error("expected the placeholder to sit inside the data-URI, prefix preserved")
    }

restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))
    if err != nil {
        t.Fatalf("ReinjectImages: %v", err)
    }
    if string(restored) != orig {
        t.Fatalf("round trip not byte-exact:\n got: %s\nwant: %s", restored, orig)
    }
}

// A data-URI embedded in function_call_output text round-trips too.
func TestCodexCodec_FunctionOutputDataURIRoundTrips(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    b64 := codexPNG("tool-output")
    orig := codexFunctionOutputLine(b64) + "\n"

rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if len(assets) != 1 {
        t.Fatalf("expected 1 asset, got %d", len(assets))
    }
    if strings.Contains(string(rewritten), b64) {
        t.Error("base64 in tool output should be externalized")
    }
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))
    if err != nil {
        t.Fatalf("ReinjectImages: %v", err)
    }
    if string(restored) != orig {
        t.Fatal("function_call_output round trip not byte-exact")
    }
}

// A single message with many images (the real Codex case) round-trips, each a
// distinct asset.
func TestCodexCodec_MultipleImagesOneMessage(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    b1, b2, b3 := codexPNG("one"), codexJPEG("two"), codexPNG("three")
    orig := `{"type":"response_item","payload":{"type":"message","role":"user","content":[` +\
        `{"type":"input_image","image_url":"data:image/png;base64,` + b1 + `"},` +\
        `{"type":"input_image","image_url":"data:image/jpeg;base64,` + b2 + `"},` +\
        `{"type":"input_image","image_url":"data:image/png;base64,` + b3 + `"}` +\
        `]}}` + "\n"

rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if len(assets) != 3 {
        t.Fatalf("expected 3 assets, got %d", len(assets))
    }
    // jpeg maps to .jpg extension.
    var sawJPG bool
    for _, a := range assets {
        if strings.HasSuffix(a.Name, ".jpg") {
            sawJPG = true
        }
    }
    if !sawJPG {
        t.Error("expected a .jpg asset from the image/jpeg data-URI")
    }
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))
    if err != nil {
        t.Fatalf("ReinjectImages: %v", err)
    }
    if string(restored) != orig {
        t.Fatal("multi-image round trip not byte-exact")
    }
}

// Identical images dedupe to one asset but round-trip both occurrences.
func TestCodexCodec_DedupesIdenticalImages(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    b64 := codexPNG("same")
    orig := codexImageLine(b64) + "\n" + codexImageLine(b64) + "\n"
    rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if len(assets) != 1 {
        t.Fatalf("identical images should dedupe to 1 asset, got %d", len(assets))
    }
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))
    if err != nil {
        t.Fatalf("ReinjectImages: %v", err)
    }
    if string(restored) != orig {
        t.Fatal("dedup round trip not byte-exact")
    }
}

// A text-only Codex transcript is a no-op.
func TestCodexCodec_NoImagesIsNoOp(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    orig := `{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}` + "\n"
    rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if assets != nil {
        t.Errorf("expected no assets, got %d", len(assets))
    }
    if string(rewritten) != orig {
        t.Error("text-only transcript should be unchanged")
    }
}

// A tiny data-URI (below the externalize threshold) is left inline.
func TestCodexCodec_LeavesTinyDataURIInline(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    tiny := base64.StdEncoding.EncodeToString([]byte("tiny"))
    if len(tiny) >= minExternalizedBase64Len {
        t.Fatalf("fixture too long: %d", len(tiny))
    }
    orig := codexImageLine(tiny) + "\n"
    rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if len(assets) != 0 || string(rewritten) != orig {
        t.Errorf("tiny data-URI must be left inline; assets=%d changed=%v", len(assets), string(rewritten) != orig)
    }
}

// Ordering: a Codex line carrying both a secret and an image data-URI —
// extraction lifts the image, leaving the secret for the redaction pass, and the
// image reinjects cleanly.
func TestCodexCodec_ExtractLeavesSecretForRedaction(t *testing.T) {
    t.Parallel()
    c := CodecFor(agent.AgentTypeCodex)
    secret := "aB3xK9mQ7pL2wR8tY4vN6cF1gH5jD0sZeW7uI2oP"
    b64 := codexPNG("secret-plus-image")
    orig := `{"type":"response_item","payload":{"type":"message","role":"user","content":[` +\
        `{"type":"input_text","text":"token ` + secret + `"},` +\
        `{"type":"input_image","image_url":"data:image/png;base64,` + b64 + `"}` +\
        `]}}` + "\n"

rewritten, assets, err := c.ExtractImages([]byte(orig))
    if err != nil {
        t.Fatalf("ExtractImages: %v", err)
    }
    if len(assets) != 1 {
        t.Fatalf("expected 1 asset, got %d", len(assets))
    }
    if strings.Contains(string(rewritten), b64) {
        t.Error("image should be externalized")
    }
    if !strings.Contains(string(rewritten), secret) {
        t.Error("the secret must remain for the downstream redaction pass")
    }
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))
    if err != nil {
        t.Fatalf("ReinjectImages: %v", err)
    }
    if string(restored) != orig {
        t.Fatal("round trip not byte-exact")
    }
}

Acmd/entire/cli/transcript/imageextract/codex_test.go+229

// Package imageextract externalizes inline base64 images from an agent's session // transcript into a checkpoint asset store, replacing each with a compact, // path-bearing placeholder, and re-injects them byte-exactly on restore. // // The transform is per-agent because transcript formats differ: only agents that // inline base64 images (Claude Code and Codex today) register a codec; every // other agent resolves to nil and its transcript flows through untouched (a // graceful no-op). All codecs share one extraction engine and reinjection routine // and differ only in how they find images (their collector). // // Correctness contract: for any transcript x from a supported agent, // ReinjectImages(ExtractImages(x)) == x, byte-for-byte. This is achieved by only // ever swapping the base64 image value in place (never re-marshalling the JSON) // and by refusing to externalize any image whose raw bytes don't re-encode to the // exact original base64 string. package imageextract

import ( "bytes" "crypto/rand" "encoding/base64" "encoding/hex" "encoding/json" "fmt" "regexp" "sort" "strconv"

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

// Asset is one externalized image. It reuses the canonical agent asset model; // Name is the stable asset filename (also the id used in the placeholder). type Asset = agent.CompactedTranscriptAsset

// ImageCodec extracts/reinjects inline images for one agent's transcript format. type ImageCodec interface { // ExtractImages lifts inline base64 images out, returning the rewritten // (placeholder-bearing) transcript plus the extracted assets. A transcript // with no externalizable images is returned unchanged with nil assets. ExtractImages(transcript []byte) (rewritten []byte, assets []Asset, err error) // ReinjectImages restores the original transcript by looking each placeholder's // asset up by Name. Placeholders whose asset is missing are left in place. ReinjectImages(transcript []byte, lookup func(name string) (Asset, bool)) ([]byte, error) }

// placeholderPrefix leads every externalized-image reference. It is deliberately // low-entropy so the (later) redaction pass never flags it, and it carries the // asset's path so an agent summarizing the stored log still understands an image // was here and where it lives. const placeholderPrefix = "entire-asset:assets/"

// placeholderRe matches a full placeholder and captures the asset name. var placeholderRe = regexp.MustCompile(entire-asset:assets/(img-[0-9a-f]+\.[a-z0-9]+))

// newAssetID returns a random hex id (16 bytes → 32 hex chars). Hex is ~4 // bits/char, below the redaction entropy threshold, so placeholders survive // redaction. The rand error is surfaced (never swallowed) so a broken entropy // source can't silently yield an all-zero, collision-prone id. Injectable for // deterministic tests. var newAssetID = func() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return "", fmt.Errorf("generate asset id: %w", err) } return hex.EncodeToString(b), nil }

// uniqueImageName returns an asset name not already in used, recording it. It // guarantees the "distinct image data -> distinct asset name" invariant the // byte-exact round trip relies on: two assets sharing a name would make // ReinjectImages restore both placeholders to whichever the lookup returns first. // With crypto/rand collisions never happen; the hex-counter suffix guarantees // termination even if a caller injects a degenerate id source. func uniqueImageName(used map[string]bool, mediaType string) (string, error) { ext := extForMedia(mediaType) id, err := newAssetID() if err != nil { return "", err } name := "img-" + id + "." + ext for suffix := 0; used[name]; suffix++ { name = "img-" + id + strconv.FormatInt(int64(suffix), 16) + "." + ext } used[name] = true return name, nil }

// minExternalizedBase64Len is the shortest base64 image value we externalize. // It must exceed the 32-char random-hex run in a placeholder so that an // externalized value can never be a substring of any placeholder — that is the // single condition under which the in-place value swap could corrupt a // placeholder and break the byte-exact round trip. As a bonus it leaves tiny // blobs (which are never real images) inline, where they cost nothing. const minExternalizedBase64Len = 64

// maxExternalizedImageBytes is the largest decoded image we externalize. Above // it the image stays inline: writeAssets stores each asset as a single git blob, // so one over agent.MaxChunkSize would become an unpushable object — the same // guard the Cursor sidecar path applies. The transcript itself is chunked under // MaxChunkSize, so an oversized image left inline still pushes fine. It is a var // (not a const) only so tests can lower it without allocating a 50MB fixture. var maxExternalizedImageBytes = agent.MaxChunkSize

var codecs = map[types.AgentType]ImageCodec{ agent.AgentTypeClaudeCode: claudeCodec{}, agent.AgentTypeCodex: codexCodec{}, }

// CodecFor returns the image codec for an agent type, or nil if the agent's // transcript is not known to inline images (a no-op). func CodecFor(t types.AgentType) ImageCodec { return codecs[t] }

// HasPlaceholders reports whether a transcript carries any externalized-image // placeholders — so restore knows to reinject regardless of the current config. func HasPlaceholders(transcript []byte) bool { return bytes.Contains(transcript, []byte(placeholderPrefix)) }

// imgHit is one image found by a collector: the bare base64 value to swap out and // its declared media type (used only to pick the asset filename extension). type imgHit struct{ data, mediaType string }

// extractImagesWith is the shared extraction engine. It parses each JSONL line, // gathers image hits via the per-agent collector, dedupes, decodes and re-encodes // to confirm the base64 is byte-exactly reversible, then swaps each value out for // a placeholder — longest value first (so a value that is a substring of another // can't orphan it) and only recording assets whose swap actually replaced bytes. func extractImagesWith(transcript []byte, collect func(v any, out *[]imgHit)) ([]byte, []Asset, error) { if len(transcript) == 0 { return transcript, nil, nil }

// Map each unique base64 image value → its asset (dedupes repeats within the // transcript; git dedupes identical blobs across checkpoints by content). seen := map[string]Asset{} var order []string // unique base64 values, later sorted longest-first usedNames := map[string]bool{} // guards distinct-data -> distinct-name

for _, line := range bytes.Split(transcript, []byte("\n")) { trimmed := bytes.TrimSpace(line) // Accept object- and array-rooted JSON lines; the collectors walk both. if len(trimmed) == 0 || (trimmed[0] != '{' && trimmed[0] != '[') {
continue
}
var v any
if err := json.Unmarshal(trimmed, &v); err != nil {
continue // non-JSON line; leave untouched
}
var hits []imgHit
collect(v, &hits)
for _, h := range hits {
if len(h.data) < minExternalizedBase64Len {
continue // too small to be a real image; also keeps it out of placeholders
}
if _, ok := seen[h.data]; ok {
continue
}
raw, err := base64.StdEncoding.DecodeString(h.data)
if err != nil {
continue // not standard base64; leave inline
}
// Only externalize if re-encoding reproduces the exact original string;
// otherwise the restore round-trip could not be byte-exact.
if base64.StdEncoding.EncodeToString(raw) != h.data {
continue
}
// Leave oversized images inline. Each asset is stored as one git blob,
// and a blob over MaxChunkSize would be unpushable; the transcript, by
// contrast, is chunked under that limit, so keeping the image inline
// stays pushable (mirrors the Cursor sidecar guard).
if len(raw) > maxExternalizedImageBytes {
continue
}
// Prefer the media type detected from the actual bytes over the declared
// one: agents mislabel it (real Codex data-URIs declare image/jpeg for
// PNG bytes), and the asset filename/manifest should reflect the content.
// This is metadata only — the transcript's declared type is untouched, so
// the round trip stays byte-exact.
mediaType := detectMediaType(raw)
if mediaType == "" {
mediaType = h.mediaType
}
name, err := uniqueImageName(usedNames, mediaType)
if err != nil {
return transcript, nil, err
}
seen[h.data] = Asset{Name: name, MediaType: mediaType, Data: raw}
order = append(order, h.data)
}
}

if len(order) == 0 {
return transcript, nil, nil
}

// Replace longest values first so that if one image's base64 is a substring of
// another's, the containing (longer) value is swapped out before the shorter
// one, keeping every asset's placeholder present. Ties broken by value for
// determinism.
sort.SliceStable(order, func(i, j int) bool {
if len(order[i]) != len(order[j]) {
return len(order[i]) > len(order[j])
}
return order[i] < order[j]
})

rewritten := transcript
assets := make([]Asset, 0, len(order))
for _, data := range order {
a := seen[data]
// Swap the base64 value itself, not a field wrapper. Agents serialize the
// enclosing JSON differently and across versions (compact vs spaced, string
// vs object image_url, data-URI vs bare), so the bare value is the only
// format-agnostic anchor. This can also swap a copy of the same base64 in a
// text field, but the round trip stays byte-exact because ReinjectImages
// restores every occurrence to the identical value.
swapped := bytes.ReplaceAll(rewritten, []byte(data), []byte(placeholderPrefix+a.Name))
if bytes.Equal(swapped, rewritten) {
// Exact bytes weren't present (e.g. JSON-escaped in the raw transcript);
// leave the image inline rather than record an asset no placeholder
// references.
continue
}
rewritten = swapped
assets = append(assets, a)
}
if len(assets) == 0 {
return transcript, nil, nil
}
return rewritten, assets, nil
}

// reinjectImages restores every placeholder to its asset's base64. It is shared
// by all codecs: the placeholder token is agent-independent, so restore only
// needs the asset lookup.
func reinjectImages(transcript []byte, lookup func(name string) (Asset, bool)) ([]byte, error) {
if !HasPlaceholders(transcript) {
return transcript, nil
}
result := transcript
done := map[string]bool{}
for _, m := range placeholderRe.FindAllSubmatch(transcript, -1) {
full, name := m[0], string(m[1])
if done[name] {
continue
}
done[name] = true
a, ok := lookup(name)
if !ok {
continue // asset unavailable; leave the placeholder (best-effort)
}
result = bytes.ReplaceAll(result, full, []byte(base64.StdEncoding.EncodeToString(a.Data)))
}
return result, nil
}

// claudeCodec handles Claude Code (and, structurally, Cursor) JSONL transcripts,
// which embed images as {"type":"image","source":{"type":"base64","media_type":…,"data":…}}.
type claudeCodec struct{}

func (claudeCodec) ExtractImages(transcript []byte) ([]byte, []Asset, error) {
return extractImagesWith(transcript, collectClaudeImages)
}

func (claudeCodec) ReinjectImages(transcript []byte, lookup func(name string) (Asset, bool)) ([]byte, error) {
return reinjectImages(transcript, lookup)
}

// codexCodec handles OpenAI Codex rollout JSONL transcripts, which embed images
// as base64 data-URIs (data:image/;base64,) — in input_image
// image_url values, user messages, and function_call_output content alike.
type codexCodec struct{}

func (codexCodec) ExtractImages(transcript []byte) ([]byte, []Asset, error) {
return extractImagesWith(transcript, collectCodexImages)
}

func (codexCodec) ReinjectImages(transcript []byte, lookup func(name string) (Asset, bool)) ([]byte, error) {
return reinjectImages(transcript, lookup)
}

// collectClaudeImages walks any decoded JSON value and gathers every inline
// base64 image block, at any nesting depth (top-level content, tool_result
// content, etc.).
func collectClaudeImages(v any, out *[]imgHit) {
switch t := v.(type) {
case map[string]any:
if t["type"] == "image" {
if src, ok := t["source"].(map[string]any); ok && src["type"] == "base64" {
if data, ok := src["data"].(string); ok && data != "" {
var mediaType string
if mt, mok := src["media_type"].(string); mok {
mediaType = mt
}
*out = append(*out, imgHit{data: data, mediaType: mediaType})
}
}
}
for _, vv := range t {
collectClaudeImages(vv, out)
}
case []any:
for _, vv := range t {
collectClaudeImages(vv, out)
}
}
}

// codexDataURIRe matches an image data-URI and captures (media subtype, base64).
// Codex serializes every inline image this way — input_image image_url values,
// user-message content, and function_call_output payloads — so keying on the
// data-URI (rather than a specific field) covers input and generated/tool images
// uniformly. The captured group is the bare base64, which is what gets swapped.
var codexDataURIRe = regexp.MustCompile(data:image/([a-zA-Z0-9.+-]+);base64,([A-Za-z0-9+/]+={0,2}))

// collectCodexImages walks any decoded JSON value and, for each string leaf,
// gathers every image data-URI it contains (a leaf may be the URI itself, e.g.
// input_image.image_url, or embed one inside larger tool output).
func collectCodexImages(v any, out *[]imgHit) {
switch t := v.(type) {
case map[string]any:
for _, vv := range t {
collectCodexImages(vv, out)
}
case []any:
for _, vv := range t {
collectCodexImages(vv, out)
}
case string:
for _, m := range codexDataURIRe.FindAllStringSubmatch(t, -1) {
*out = append(*out, imgHit{data: m[2], mediaType: "image/" + m[1]})
}
}
}

const (
mediaTypePNG = "image/png"
mediaTypeJPEG = "image/jpeg"
mediaTypeGIF = "image/gif"
mediaTypeWEBP = "image/webp"
)

// detectMediaType returns the image media type implied by the leading magic
// bytes, or "" if unrecognized (caller falls back to the declared type).
func detectMediaType(raw []byte) string {
switch {
case bytes.HasPrefix(raw, []byte("\x89PNG\r\n\x1a\n")):
return mediaTypePNG
case bytes.HasPrefix(raw, []byte{0xFF, 0xD8, 0xFF}):
return mediaTypeJPEG
case bytes.HasPrefix(raw, []byte("GIF87a")), bytes.HasPrefix(raw, []byte("GIF89a")):
return mediaTypeGIF
case len(raw) >= 12 && bytes.HasPrefix(raw, []byte("RIFF")) && bytes.Equal(raw[8:12], []byte("WEBP")):
return mediaTypeWEBP
default:
return ""
}
}

func extForMedia(mediaType string) string {
switch mediaType {
case mediaTypePNG:
return "png"
case mediaTypeJPEG:
return "jpg"
case mediaTypeGIF:
return "gif"
case mediaTypeWEBP:
return "webp"
default:
return "bin"
}
}\

\
Acmd/entire/cli/transcript/imageextract/imageextract.go+375\
\
```\
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\
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\
258\
259\
260\
261\
262\
263\
264\
265\
266\
267\
268\
269\
270\
271\
272\
273\
274\
275\
276\
277\
278\
279\
280\
281\
282\
283\
284\
285\
286\
287\
288\
289\
290\
291\
292\
293\
294\
295\
296\
297\
298\
299\
300\
301\
302\
303\
304\
305\
306\
307\
308\
309\
310\
311\
312\
313\
314\
315\
316\
317\
318\
319\
320\
321\
322\
323\
324\
325\
326\
327\
328\
329\
330\
331\
332\
333\
334\
335\
336\
337\
338\
339\
340\
341\
342\
343\
344\
345\
346\
347\
348\
349\
350\
351\
352\
353\
354\
355\
356\
357\
358\
359\
360\
361\
362\
363\
364\
365\
366\
367\
368\
369\
370\
371\
372\
373\
374\
375\
376\
377\
378\
379\
380\
381\
382\
383\
384\
385\
386\
387\
388\
389\
390\
391\
392\
393\
394\
395\
396\
397\
398\
399\
400\
401\
\
package imageextract\
\
import (\
    "encoding/base64"\
    "errors"\
    "math"\
    "regexp"\
    "strings"\
    "testing"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
)\
\
var errTestRand = errors.New("simulated rand failure")\
\
func lookupFrom(assets []Asset) func(string) (Asset, bool) {\
    return func(name string) (Asset, bool) {\
        for _, a := range assets {\
            if a.Name == name {\
                return a, true\
            }\
        }\
        return Asset{}, false\
    }\
}\
\
func claudeLine(b64 string) string {\
    return `{"type":"user","message":{"role":"user","content":[` +\
        `{"type":"text","text":"look at this"},` +\
        `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + b64 + `"}}` +\
        `]}}`\
}\
\
// The core contract: extract then reinject reproduces the original bytes exactly.\
func TestClaudeCodec_RoundTripByteExact(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    if c == nil {\
        t.Fatal("expected a codec for Claude Code")\
    }\
    b64 := base64.StdEncoding.EncodeToString([]byte("\x89PNG\r\n\x1a\nfake-png-bytes-with-enough-length-to-be-a-real-image\x00\x01\x02"))\
    orig := claudeLine(b64) + "\n{\"type\":\"assistant\",\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"ok\"}]}}\n"\
\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 1 {\
        t.Fatalf("expected 1 asset, got %d", len(assets))\
    }\
    if strings.Contains(string(rewritten), b64) {\
        t.Error("base64 must be gone from the rewritten transcript")\
    }\
    if !strings.Contains(string(rewritten), placeholderPrefix) {\
        t.Error("rewritten transcript should carry a placeholder")\
    }\
    if assets[0].MediaType != mediaTypePNG {\
        t.Errorf("asset media type = %q, want image/png", assets[0].MediaType)\
    }\
\
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
    if err != nil {\
        t.Fatalf("ReinjectImages: %v", err)\
    }\
    if string(restored) != orig {\
        t.Fatalf("round-trip not byte-exact:\n got: %s\nwant: %s", restored, orig)\
    }\
}\
\
// A transcript with no images is returned unchanged with no assets.\
func TestClaudeCodec_NoImagesIsNoOp(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    orig := `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}` + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if assets != nil {\
        t.Errorf("expected no assets, got %d", len(assets))\
    }\
    if string(rewritten) != orig {\
        t.Errorf("no-image transcript should be unchanged")\
    }\
}\
\
// Identical images dedupe to one asset but round-trip both occurrences.\
func TestClaudeCodec_DedupesIdenticalImages(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    b64 := base64.StdEncoding.EncodeToString([]byte("same-image-bytes-repeated-with-enough-length-to-externalize"))\
    orig := claudeLine(b64) + "\n" + claudeLine(b64) + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 1 {\
        t.Fatalf("identical images should dedupe to 1 asset, got %d", len(assets))\
    }\
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
    if err != nil {\
        t.Fatalf("ReinjectImages: %v", err)\
    }\
    if string(restored) != orig {\
        t.Fatalf("round-trip mismatch for duplicated image")\
    }\
}\
\
// When one image's base64 is a substring of another's, the round trip must still\
// be byte-exact (longest-first replacement guarantees this).\
func TestClaudeCodec_SubstringImagesRoundTrip(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    long := base64.StdEncoding.EncodeToString([]byte(\
        "prefix-bytes-AAAABBBBCCCCDDDD-and-a-considerably-longer-image-tail-payload-so-a-64-char-substring-fits-xyz"))\
    // A canonical base64 substring of long (>= threshold) that decodes/re-encodes\
    // cleanly, taken from a non-zero offset so it is genuinely embedded.\
    var short string\
    for i := 4; i+64 <= len(long); i += 4 {\
        cand := long[i : i+64]\
        if raw, err := base64.StdEncoding.DecodeString(cand); err == nil && base64.StdEncoding.EncodeToString(raw) == cand {\
            short = cand\
            break\
        }\
    }\
    if short == "" {\
        t.Fatal("could not construct a canonical base64 substring")\
    }\
    // Shorter block first, so first-seen order would (without the sort) replace it\
    // before the containing longer value.\
    orig := claudeLine(short) + "\n" + claudeLine(long) + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 2 {\
        t.Fatalf("expected 2 assets, got %d", len(assets))\
    }\
    // Longest-first replacement means both assets have a live placeholder (neither\
    // is orphaned by the other's swap).\
    for _, a := range assets {\
        if !strings.Contains(string(rewritten), placeholderPrefix+a.Name) {\
            t.Errorf("asset %s has no placeholder in the rewritten transcript (orphaned)", a.Name)\
        }\
    }\
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
    if err != nil {\
        t.Fatalf("ReinjectImages: %v", err)\
    }\
    if string(restored) != orig {\
        t.Fatalf("substring round-trip not byte-exact:\n got: %s\nwant: %s", restored, orig)\
    }\
}\
\
// Even if the id source degenerates to a constant, distinct images must still get\
// distinct names so the round trip stays byte-exact (no asset shadows another).\
func TestClaudeCodec_DistinctNamesUnderCollidingIDSource(t *testing.T) {\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    orig := newAssetID\
    newAssetID = func() (string, error) { return "deadbeefdeadbeefdeadbeefdeadbeef", nil } // constant\
    defer func() { newAssetID = orig }()\
\
    img1 := base64.StdEncoding.EncodeToString([]byte("first-distinct-image-payload-long-enough-to-externalize"))\
    img2 := base64.StdEncoding.EncodeToString([]byte("second-distinct-image-payload-long-enough-to-externalize"))\
    in := claudeLine(img1) + "\n" + claudeLine(img2) + "\n"\
\
    rewritten, assets, err := c.ExtractImages([]byte(in))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 2 {\
        t.Fatalf("want 2 assets, got %d", len(assets))\
    }\
    if assets[0].Name == assets[1].Name {\
        t.Fatalf("distinct images got the same name %q", assets[0].Name)\
    }\
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
    if err != nil {\
        t.Fatalf("ReinjectImages: %v", err)\
    }\
    if string(restored) != in {\
        t.Fatalf("round trip broke under colliding id source:\n got: %s\nwant: %s", restored, in)\
    }\
}\
\
// The same base64 appearing in both an image and a text field round-trips\
// byte-exactly: the value swap is value-preserving and reversible, so every\
// occurrence is restored to the identical bytes on reinject.\
func TestClaudeCodec_Base64InTextRoundTrips(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    b64 := base64.StdEncoding.EncodeToString([]byte("shared-image-and-text-payload-long-enough-to-externalize"))\
    textLine := `{"type":"user","message":{"role":"user","content":[{"type":"text","text":"raw was ` + b64 + `"}]}}`\
    in := textLine + "\n" + claudeLine(b64) + "\n"\
\
    rewritten, assets, err := c.ExtractImages([]byte(in))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 1 {\
        t.Fatalf("want 1 asset, got %d", len(assets))\
    }\
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
    if err != nil {\
        t.Fatalf("ReinjectImages: %v", err)\
    }\
    if string(restored) != in {\
        t.Fatalf("round trip not byte-exact:\n got: %s\nwant: %s", restored, in)\
    }\
}\
\
// Regression: Claude Code serializes image content blocks with a space after the\
// colon ("data": "<b64>") as well as compactly ("data":"<b64>"). Both forms must\
// externalize and round-trip. (A data-field-scoped swap missed the spaced form.)\
func TestClaudeCodec_SpacedAndCompactDataFields(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    for _, tc := range []struct {\
        name, line string\
    }{\
        {"compact", `{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data":"%s"}}`},\
        {"spaced", `{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "%s"}}`},\
    } {\
        b64 := base64.StdEncoding.EncodeToString([]byte("spaced-vs-compact-payload-long-enough-to-externalize-" + tc.name))\
        in := strings.Replace(tc.line, "%s", b64, 1) + "\n"\
        rewritten, assets, err := c.ExtractImages([]byte(in))\
        if err != nil {\
            t.Fatalf("[%s] ExtractImages: %v", tc.name, err)\
        }\
        if len(assets) != 1 {\
            t.Fatalf("[%s] want 1 asset, got %d", tc.name, len(assets))\
        }\
        if strings.Contains(string(rewritten), b64) {\
            t.Errorf("[%s] base64 not externalized", tc.name)\
        }\
        restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
        if err != nil {\
            t.Fatalf("[%s] ReinjectImages: %v", tc.name, err)\
        }\
        if string(restored) != in {\
            t.Fatalf("[%s] round trip not byte-exact", tc.name)\
        }\
    }\
}\
\
// A crypto/rand failure surfaces as an error instead of a silent all-zero id.\
func TestClaudeCodec_IDGenerationErrorSurfaces(t *testing.T) {\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    orig := newAssetID\
    newAssetID = func() (string, error) { return "", errTestRand }\
    defer func() { newAssetID = orig }()\
\
    b64 := base64.StdEncoding.EncodeToString([]byte("payload-long-enough-to-externalize-and-trigger-id-gen"))\
    _, _, err := c.ExtractImages([]byte(claudeLine(b64) + "\n"))\
    if err == nil {\
        t.Fatal("expected an error when id generation fails, got nil")\
    }\
}\
\
// An array-rooted JSONL line carrying an image is walked like an object line.\
func TestClaudeCodec_ArrayRootedLine(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    b64 := base64.StdEncoding.EncodeToString([]byte("array-rooted-line-image-payload-long-enough-to-externalize"))\
    in := `[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"` + b64 + `"}}]` + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(in))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 1 {\
        t.Fatalf("array-rooted line: want 1 asset, got %d", len(assets))\
    }\
    restored, err := c.ReinjectImages(rewritten, lookupFrom(assets))\
    if err != nil {\
        t.Fatalf("ReinjectImages: %v", err)\
    }\
    if string(restored) != in {\
        t.Fatalf("array-rooted round trip not byte-exact")\
    }\
}\
\
// Base64 values too short to be a real image are left inline (and can therefore\
// never collide with a placeholder's hex id).\
func TestClaudeCodec_LeavesTinyBase64Inline(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    tiny := base64.StdEncoding.EncodeToString([]byte("tiny-blob")) // < minExternalizedBase64Len\
    if len(tiny) >= minExternalizedBase64Len {\
        t.Fatalf("test fixture too long: %d", len(tiny))\
    }\
    orig := claudeLine(tiny) + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 0 || string(rewritten) != orig {\
        t.Errorf("tiny base64 must be left inline; assets=%d changed=%v", len(assets), string(rewritten) != orig)\
    }\
}\
\
// Images whose decoded bytes exceed maxExternalizedImageBytes are left inline: as\
// a single asset blob they could become an unpushable git object, so (like the\
// Cursor sidecar path) they stay in the transcript, which is chunked to stay\
// pushable. Not parallel: it lowers the shared cap to avoid a 50MB fixture.\
func TestClaudeCodec_LeavesOversizedImageInline(t *testing.T) {\
    c := CodecFor(agent.AgentTypeClaudeCode)\
\
    restore := maxExternalizedImageBytes\
    maxExternalizedImageBytes = 8\
    t.Cleanup(func() { maxExternalizedImageBytes = restore })\
\
    // 72 bytes: over the lowered cap, and its base64 clears minExternalizedBase64Len\
    // so only the size guard (not the min-length filter) can keep it inline.\
    raw := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)\
    b64 := base64.StdEncoding.EncodeToString(raw)\
    if len(b64) < minExternalizedBase64Len {\
        t.Fatalf("fixture too short to exercise the max guard: %d", len(b64))\
    }\
    orig := claudeLine(b64) + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 0 || string(rewritten) != orig {\
        t.Errorf("oversized image must be left inline; assets=%d changed=%v", len(assets), string(rewritten) != orig)\
    }\
}\
\
// Non-base64 image sources (e.g. url) and non-decodable data are left inline.\
func TestClaudeCodec_LeavesNonBase64Inline(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    orig := `{"type":"user","message":{"content":[{"type":"image","source":{"type":"url","url":"https://x/y.png"}}]}}` + "\n"\
    rewritten, assets, err := c.ExtractImages([]byte(orig))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    if len(assets) != 0 || string(rewritten) != orig {\
        t.Errorf("url image source must be left inline; assets=%d changed=%v", len(assets), string(rewritten) != orig)\
    }\
}\
\
// Agents that don't inline images in the transcript have no codec (graceful\
// no-op upstream). Cursor is included deliberately: its images live in a separate\
// SQLite store, captured via the SidecarImageProvider path, not a transcript codec.\
func TestCodecFor_NonImageAgentsAreNil(t *testing.T) {\
    t.Parallel()\
    for _, at := range []string{"Cursor", "Gemini CLI", "OpenCode", "Pi", "Factory AI Droid", "Copilot CLI"} {\
        if CodecFor(types.AgentType(at)) != nil {\
            t.Errorf("agent %q should not have an image codec yet", at)\
        }\
    }\
}\
\
// The placeholder must stay low-entropy so the downstream redaction pass never\
// flags it. Redaction's entropy detector runs over each [A-Za-z0-9+_=-]{10,}\
// RUN (threshold 4.5 bits/char), not the whole string, so mirror that here.\
func TestPlaceholder_RunsAreLowEntropy(t *testing.T) {\
    t.Parallel()\
    c := CodecFor(agent.AgentTypeClaudeCode)\
    b64 := base64.StdEncoding.EncodeToString([]byte("entropy-check-bytes-xyz-padded-to-exceed-the-externalize-threshold"))\
    rewritten, _, err := c.ExtractImages([]byte(claudeLine(b64) + "\n"))\
    if err != nil {\
        t.Fatalf("ExtractImages: %v", err)\
    }\
    ph := placeholderRe.Find(rewritten)\
    if ph == nil {\
        t.Fatal("no placeholder produced")\
    }\
    runRe := regexp.MustCompile(`[A-Za-z0-9+_=-]{10,}`)\
    runs := runRe.FindAll(ph, -1)\
    if len(runs) == 0 {\
        t.Fatalf("expected at least one detector-sized run in %s", ph)\
    }\
    for _, run := range runs {\
        if e := shannonBitsPerChar(run); e >= 4.5 {\
            t.Errorf("placeholder run %q entropy %.2f >= 4.5 — redaction could flag it", run, e)\
        }\
    }\
}\
\
func shannonBitsPerChar(b []byte) float64 {\
    if len(b) == 0 {\
        return 0\
    }\
    var counts [256]int\
    for _, c := range b {\
        counts[c]++\
    }\
    var e float64\
    n := float64(len(b))\
    for _, c := range counts {\
        if c == 0 {\
            continue\
        }\
        p := float64(c) / n\
        e -= p * math.Log2(p)\
    }\
    return e\
}\
```\
\
Acmd/entire/cli/transcript/imageextract/imageextract\_test.go+401