[Home](/content/site-root.html)

Log in

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

`7cd6662`→[main](/content/gh/entireio/cli/commits/main/index.html)·

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

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

## Changes

23

- api/checkpoint

- Mmetadata.go+30

- cmd/entire/cli

- agent

- Magent.go+14

- Mcapabilities.go+12

- cursor

- Aimages.go+295

- Aimages\_test.go+263

- checkpoint

- Maliases.go+1

- Mpersistent.go+172/-29

- Apersistent\_assets\_test.go+356

- integration\_test

- Acodex\_image\_externalize\_test.go+146

- Acursor\_image\_externalize\_test.go+321

- Aimage\_externalize\_test.go+194

- Mtestenv.go+3

- paths

- Mpaths.go+7

- settings

- Msettings.go+27

- Asettings\_images\_test.go+63

- strategy

- Acondense\_images\_test.go+165

- Mmanual\_commit\_condensation.go+95/-1

- Mmanual\_commit\_hooks.go+48/-7

- Mmanual\_commit\_opf\_rewrite.go+19

- Mmanual\_commit\_opf\_rewrite\_test.go+87

- transcript/imageextract

- Acodex\_test.go+229

- Aimageextract.go+375

- Aimageextract\_test.go+401

```
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 <chats>/<workspace>/<sessionID>/store.db and points the
// test override env at <chats>. 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-<hash>.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
	// <chats>/<workspace-hash>/<conversationID>/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-<hash>.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/<type>;base64,<data>) — 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
