compaction strategies share stored folds through the context
strategies no longer import each other to see stored summaries. a FoldPlugin supplies them, and the runtime hands every strategy context.prior, which splits history into stored folds and the rest they don't cover, leaving out the active strategy's own provider. lcm-memory provides lcm's folds; rolling and snapcompact read them from the context, and snapcompact keeps them as text ahead of its frames. a compaction strategy enabled by name, for a session or globally, now displaces one that only the defaults enable, so a new default strategy cannot break an older choice. snapcompact-memory hands the snapcompact archive to other strategies as text folds and adds transcript_grep and transcript_read over the durable transcript. snapcompact's frame cap follows oh-my-pi's per-provider image budgets and a 3 MB inline budget (claude uploads through the files api). it reports its window and trigger to /context, and a forced compaction keeps a tail sized to the request, so /compact archives under a large window. a model id several catalog providers publish, served through a gateway the catalog does not list, answers with the input kinds they agree on and the smallest limits instead of nothing. the compaction notice says what the strategy did: archived as frames, folded, or summarized.
12345567897 unmodified lines17181920212223242526272831 unmodified lines6061626360646566676869707172737475767778798068818283848512 unmodified lines# compaction
Compaction changes only what a request shows the model. The durable transcript, `/tree`, and forks always keep the full conversation.
The default session uses `rolling`. Selecting another strategy (`snapcompact` or `lcm`) through `/extensions` disables the current one in the same reload. A session with the built-in extensions keeps one compaction strategy enabled. The registry also permits a custom host with no compaction strategy installed.The default session uses `rolling`. Selecting another strategy (`snapcompact` or `lcm`) through `/extensions` disables the current one in the same reload. A strategy enabled by name, for a session or globally, displaces a default one it did not choose, so a new default strategy cannot break an older choice. A session with the built-in extensions keeps one compaction strategy enabled. The registry also permits a custom host with no compaction strategy installed.
`/compact` compacts now with the session's strategy. `/compact <strategy>` first makes the named strategy the session's own, as `/extensions` would, then compacts with it. Later requests read the projection that strategy saved. For example, `/compact rolling` before switching to a model without image input leaves a text summary that the new model can read.
A strategy returns prepared inputs and an observation for `/context`. The observation describes that preparation. The inspector does not read strategy state. The durable transcript supplies the source rows for each derived view.7 unmodified lines`lcm` uses durable source rows for summary ranges and plain model inputs for its verbatim tail. It preserves every unsummarized user unit and the latest whole user/tool unit. Source references on live projected inputs would let future strategies cut history within those unit boundaries.
Live source references need the sequence IDs from the transaction that commits inputs. The session coordinator can then project those identified entries before preparing a request. Its current commit callback returns only a timestamp, and its in-memory entries have no sequence field. Callers that do not need references can keep the plain-input path. Text or list positions cannot identify rows reliably because messages can repeat and provider projection can change item counts. An excursion marker belongs to its parent session. The branch has different row references, so returning its outcome requires an explicit link to the parent marker.
## stored folds
A strategy reads folds another strategy stored through `context.prior(history)`, never by importing that strategy. It returns `Prior(folds, rest)`: the stored summaries, oldest first, and the history they do not cover. Without stored folds, `folds` is empty and `rest` is the history unchanged. The extension that owns the storage registers a `FoldPlugin`; the runtime applies every enabled provider in registry order, except a provider owned by the active strategy, which reads its own state. `lcm-memory` provides LCM's folds and `snapcompact-memory` the snapcompact archive, so each stays visible after a switch away. `lcm-memory` comes first, so the archive covers history past LCM's folds. `lcm` ignores `prior`.
## lcm
`lcm` implements the source-backed memory part of [Lossless Context Management](https://arxiv.org/html/2605.04050v1). It stores leaf summaries, condensed parent summaries, child links, and a covered source cursor in SQLite. The durable transcript supplies their source rows. The summary tree belongs to one session. A fork copies complete prefix summaries and their links with remapped row and node IDs.
31 unmodified lines`snapcompact` archives evicted history as rendered bitmap frames that a vision model reads directly, instead of a model-written summary. The frames are X11 8x13 pixel-font text drawn by the local `albedo-render` binary and cached in `snapcompact_frames` by geometry and content. The request contains the frames, then the verbatim tail.
The saved archive is the normalized text of everything before the cut, stored in `snapcompact_archive` with a user-message cut like rolling's. Frames are re-derived from that text for each request, so after a model switch the same archive renders in the new model's frame shape. Recompaction appends newly evicted history to the saved text. Unchanged leading frames keep their cache keys, so the request prefix stays stable.
The archive keeps at most a frame budget: the model family's image cap (60 frames for Anthropic, OpenAI, Google, and Qwen models, 20 for others), and no more than `archivePercent` of the window at the estimated cost of one full frame. When the archive exceeds the budget, whole frames between the first frame and the newest are dropped. The first image's caption then states how many characters were dropped.The archive keeps at most a frame budget, the smallest of:
- the provider's image budget, from oh-my-pi: 90 for `anthropic`, `amazon-bedrock`, and `openrouter`, 200 for `openai`, `openai-codex`, and the Google providers, 10 for `umans`, and 5 for any other provider or a model no catalog knows. albedo's `claude` and `antigravity` providers take 90, since antigravity also serves Claude models.- 60 frames of inline image data (3 MB at about 50 KB a frame), except on `claude`, which uploads images through the files API- 80 frames- `archivePercent` of the window at the estimated cost of one full frame
When the archive exceeds the budget, whole frames between the first frame and the newest are dropped. The first image's caption then states how many characters were dropped.
Other strategies' stored folds stay as text ahead of the frames, so the budget only drops raw rows. `snapcompact` requires `snapcompact-memory`, which keeps the archive readable after a switch: another strategy receives it as text pages through `context.prior`, and the pages stand in for exactly the history the archive's cut covers. `snapcompact-memory` also provides `transcript_grep` and `transcript_read`, which search and page the session's full durable transcript, including history a frame renders illegibly or the budget dropped.
When the catalog reports that the current model reads no image input, `snapcompact` uses rolling's text compaction for that model. The frame archive stays saved for a later model that reads images. A model without catalog modalities is treated as vision-capable.
```json{ "snapcompact": { "contextWindowTokens": 200000, "triggerPercent": 90, "tailPercent": 10, "archivePercent": 20, "maxFrames": 60 } }```
`contextWindowTokens` and `maxFrames` are optional. `maxFrames` replaces the model family's frame cap.`contextWindowTokens` and `maxFrames` are optional. `maxFrames` replaces the provider caps.
## configure
```json12 unmodified lines40 unmodified lines4142434445454647484987 unmodified lines13713813914014114214314414514614740 unmodified lines`ModelsPlugin` supplies catalogued model facts and provider model lists. `ModelProviderPlugin` declares its models.dev namespace and resolves a tagged saved profile into an `Upstream`: a `stream` function over albedo's request, event, and turn types, plus the endpoint, replay protocol, and an `explain` for failures. A provider with its own wire format encodes an `openai_api.Exchange` and passes it with its own `stream.Reducer` to `openai_api.exchange`, which keeps HTTP, SSE framing, limits, and callbacks shared; `antigravity` does this. `LoginPlugin` supplies a browser sign-in that the daemon runs for every client; see [model authentication](auth.md#sign-in-api).
`ServicePlugin(Service(handle))` serves HTTP from the daemon while its extension is enabled globally (sessions do not select services). Requests to `/<extension>/...` go to `handle(daemon, path, request)` with the path below the mount. The daemon token does not apply there, so a service owns its own authentication; requests carrying an `Origin` header are still refused, so a web page cannot reach a local service that spends your credentials. The daemon's own routes (`health`, `sessions`, `models`, `auth`, `shutdown`) always win. `extension.Daemon` gives the service `home`, `upstream(profile, model, session)` to resolve a saved profile outside any session, catalog `models`, and the daemon's `sessions`. Set `ALBEDO_PORT` in the daemon's environment to pin its port so a service has a stable base url; the default is a free port chosen at start. Built-in dependencies keep these layers explicit: `codex -> openai -> models`. See [models](models.md) and [model authentication](auth.md).
`CompactionPlugin` supplies a history strategy; see [compaction](compaction.md). one enabled compaction strategy owns the request-history view in a built-in session. a strategy receives chronological history and must preserve tool call/result associations; it must not replace the durable transcript.`CompactionPlugin` supplies a history strategy; `FoldPlugin` supplies stored folds any strategy reads through `context.prior`; see [compaction](compaction.md). one enabled compaction strategy owns the request-history view in a built-in session. a strategy receives chronological history and must preserve tool call/result associations; it must not replace the durable transcript.
## extension settings
optional `$ALBEDO_HOME/extensions.json` (default `~/.albedo/extensions.json`) holds named extension settings separately from provider credentials and session selection. settings are read when preparing a worker; changing a file does not mutate an existing session composition. missing sections use extension defaults. invalid settings fail preparation without replacing the live worker.87 unmodified lines## rolling compaction
`rolling` is the enabled-by-default compaction strategy: summary, recent-user recap, then a verbatim tail, triggered by remaining context. see [compaction](compaction.md) for its contract, settings, and state handling.
## snapcompact
`snapcompact` is an alternative compaction strategy: evicted history as rendered bitmap frames under per-provider image budgets. it requires `snapcompact-memory`, which is enabled by default, keeps the archive readable as text folds after a switch, and provides `transcript_grep` and `transcript_read`. see [compaction](compaction.md).
## lcm compaction
`lcm` is an alternative compaction strategy with source-backed hierarchical summaries. enabling it disables `rolling` in the same reload. `lcm-memory` keeps `lcm_list`, `lcm_grep`, `lcm_describe`, and `lcm_expand` available after a switch back to `rolling`, so stored folds remain accessible even if a rolling summary omits them. see [compaction](compaction.md) for the handoff and retrieval limits.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164import albedo/daemon/storeimport albedo/harness/compactionimport albedo/harness/extensionimport albedo/harness/extensionsimport albedo/harness/extensions/snapcompact/extension as snapcompactimport albedo/harness/runtimeimport albedo/openai_api/typesimport gleam/listimport gleam/option.{None}import gleam/stringimport gleeunit/shouldimport sqlight
@external(erlang, "albedo_env_test_support", "with_home")fn with_home(home: String, run: fn() -> a) -> a
@external(erlang, "albedo_skills_test_support", "fixture")fn fixture() -> #(String, String, String)
@external(erlang, "albedo_skills_test_support", "write")fn write(base: String, relative: String, content: String) -> Nil
@external(erlang, "albedo_skills_test_support", "cleanup")fn cleanup(root: String) -> Nil
/// Folds the first input into a stored summary, as LCM would.fn folding(name: String) -> compaction.Folds { compaction.Folds(name, "fixture", fn(_, _, history) { case history { [first, ..rest] -> Ok(compaction.Prior( [types.User(name <> " fold of " <> text(first))], rest, )) [] -> compaction.no_prior(history) } })}
fn text(input: types.Input) -> String { case input { types.User(value) | types.Assistant(value) -> value _ -> "" }}
fn strategy(name: String) -> extension.Extension { extension.Extension( name, "fixture strategy", [], [ extension.CompactionPlugin( compaction.Strategy(name, fn(_, history) { Ok(compaction.Prepared(history, None)) }), ), ], fn(_) { Ok(Nil) }, )}
pub fn providers_compose_in_order_test() { let assert Ok(ledger) = store.start(":memory:", "") let prior = compaction.compose_prior([folding("a"), folding("b")], ledger, "s") let assert Ok(compaction.Prior(folds, rest)) = prior([types.User("one"), types.User("two"), types.User("three")]) folds |> should.equal([types.User("a fold of one"), types.User("b fold of two")]) rest |> should.equal([types.User("three")])}
pub fn snapcompact_keeps_folds_as_text_ahead_of_frames_test() { let folds = extension.Extension( "folds", "fixture fold provider", [], [extension.FoldPlugin(folding("stored"))], fn(_) { Ok(Nil) }, ) let installed = [folds, snapcompact.extension(), snapcompact.memory()] let assert Ok(host) = runtime.start_with_config( ":memory:", extensions.Config(installed, [ "folds", "snapcompact", "snapcompact-memory", ]), ) let assert Ok(session) = runtime.open_session(host, "snap", "/tmp") let history = [ types.User("oldest"), types.User("u1 " <> string.repeat("a", 6000)), types.Assistant("a1 " <> string.repeat("b", 6000)), types.User("latest"), ] let assert Ok(projected) = runtime.compact_history_scoped( host, session, "claude-test", "claude-test", "", "system", fn(_) { Error("snapcompact never summarizes") }, history, ) // The fold stays readable text; only history past it becomes frames. let assert [types.User(fold), types.UserImage(_, _), ..] = projected fold |> should.equal("stored fold of oldest") projected |> list.last |> should.equal(Ok(types.User("latest"))) runtime.stop(host)}
pub fn explicit_global_strategy_displaces_a_new_default_test() { let #(root, _, home) = fixture() use <- with_home(home) // Chosen before "fresh" became the built-in default strategy. write( home, "extensions.json", "{\"enabled\":{\"older\":true,\"base\":false}}", ) let config = extensions.Config([strategy("fresh"), strategy("base"), strategy("older")], [ "fresh", ]) let assert Ok(host) = runtime.start_with_config(":memory:", config) let assert Ok(_) = runtime.open_session(host, "a", "/tmp") enabled(host, "a") |> should.equal(["older"]) runtime.stop(host) cleanup(root)}
pub fn session_strategy_displaces_a_new_default_test() { let #(root, _, home) = fixture() use <- with_home(home) let config = extensions.Config([strategy("fresh"), strategy("older")], ["fresh"]) let assert Ok(host) = runtime.start_with_config(":memory:", config) // A session choice recorded without an override for "fresh", as when the // choice predates "fresh" being installed. let assert Ok(_) = store.query(runtime.ledger(host), fn(db) { sqlight.exec( "INSERT INTO session_extensions(session,name,enabled) VALUES('a','older',1)", db, ) }) let assert Ok(_) = runtime.open_session(host, "a", "/tmp") enabled(host, "a") |> should.equal(["older"]) runtime.stop(host) cleanup(root)}
fn enabled(host: runtime.Runtime, session: String) -> List(String) { let assert Ok(summaries) = runtime.extension_summaries(host, session) summaries |> list.filter(fn(summary) { summary.enabled }) |> list.map(fn(summary) { summary.name })}17 unmodified lines1819202122232425262728293031323334353637383940414243444546474849505152535455566 unmodified lines63646566406768697044457172737454 unmodified lines12913013113210813313413513613713891 unmodified lines17 unmodified lines
let assert Some(agreed) = models.lookup_at(file, "shared-model", "") agreed.context_tokens |> should.equal(Some(400_000)) string.contains(agreed.source, "model id") |> should.be_true // No endpoint names who serves it, so no provider is guessed. agreed.provider |> should.equal("") agreed.endpoint |> should.equal(None) // A provider that reports no input kinds does not veto the others'. agreed.input_modalities |> should.equal(["text", "image"])
cleanup(root)}
/// A gateway the catalog does not list serves a model several providers/// publish with different limits: the smallest limits and the input kinds/// every provider reports answer.pub fn disputed_limits_answer_with_the_smallest_test() { let #(root, _, home) = fixture() let file = write(home, "models.json", catalog)
let assert Some(disputed) = models.lookup_at(file, "disputed", "https://gateway.example.com/v1") disputed.provider |> should.equal("") disputed.context_tokens |> should.equal(Some(8000)) string.contains(disputed.source, "smallest limits of 2 providers") |> should.be_true // An endpoint that names a provider still takes that provider's limits. let assert Some(other) = models.lookup_at(file, "disputed", "https://other.example.com/v1") other.provider |> should.equal("other") other.context_tokens |> should.equal(Some(16_000))
cleanup(root)}
pub fn provider_scoped_lookup_keeps_metadata_with_its_provider_test() { let #(root, _, home) = fixture() let file = write(home, "models.json", catalog) let assert Some(info) =6 unmodified lines assert models.lookup_provider_at(file, "mirror", "disputed") != None cleanup(root)}
pub fn unknown_disputed_and_missing_catalogs_stay_unknown_test() {pub fn unknown_and_missing_catalogs_stay_unknown_test() { let #(root, _, home) = fixture() let file = write(home, "models.json", catalog)
// Two providers disagree about the same id and no endpoint decides it. models.lookup_at(file, "disputed", "") |> should.equal(None) models.lookup_at(file, "absent-model", "") |> should.equal(None) models.lookup_at(home <> "/missing.json", "shared-model", "") |> should.equal(None) let broken = write(home, "broken.json", "{not json")54 unmodified lines // The rewritten file answers exactly as the original did. let assert Some(again) = models.lookup_at(file, "shared-model", "https://mirror.example.com/v1") again |> should.equal(info) models.lookup_at(file, "disputed", "") |> should.equal(None) let assert Some(disputed) = models.lookup_at(file, "disputed", "") disputed.context_tokens |> should.equal(Some(8000)) models.list_at(file, "openai", "") |> should.equal(["only-openai", "shared-model"])
cleanup(root)91 unmodified lines123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164import albedo/daemon/conversationimport albedo/harness/extensionsimport albedo/harness/extensions/rolling/extension as rollingimport albedo/harness/extensions/snapcompact/extension as snapcompactimport albedo/harness/extensions/snapcompact/transcriptimport albedo/harness/runtimeimport albedo/openai_api/typesimport gleam/intimport gleam/listimport gleam/option.{None, Some}import gleam/stringimport gleeunit/should
// What snapcompact-memory adds: the archive as text folds for another// strategy, and the transcript tools. Frames need albedo-render built.
fn host() { let config = snapcompact.Config(Some(10_000), 90, 10, 60, None, True) let installed = [ snapcompact.configured_extension(config), rolling.configured_extension(rolling.Config(Some(1_000_000), 90, 25)), snapcompact.memory(), ] let assert Ok(host) = runtime.start_with_config( ":memory:", extensions.Config(installed, ["snapcompact", "snapcompact-memory"]), ) let ledger = runtime.ledger(host) let assert Ok(_) = conversation.initialise(ledger) let assert Ok(_) = conversation.create( ledger, conversation.Info( "snap", "new session", "/tmp", "provider", "model", types.Responses, conversation.Idle, None, None, ), ) let assert Ok(session) = runtime.open_session(host, "snap", "/tmp") #(host, session)}
fn turns(n: Int) -> List(types.Input) { list.repeat(Nil, n) |> list.index_map(fn(_, index) { index + 1 }) |> list.flat_map(fn(i) { [ types.User("u" <> int.to_string(i) <> " " <> string.repeat("a", 2000)), types.Assistant( "a" <> int.to_string(i) <> " " <> string.repeat("b", 2000), ), ] })}
fn prepare(host, session, history) { let assert Ok(prepared) = runtime.prepare_history_with( host, session, "model", "", fn(_) { Error("must not summarize") }, history, ) prepared}
pub fn rolling_reads_the_archive_as_text_folds_test() { let #(host, session) = host() let history = turns(6) let assert Ok(_) = runtime.compact_history_scoped( host, session, "model", "source", "", "", fn(_) { Error("must not summarize") }, history, ) let assert Ok(Some(archive)) = snapcompact.load_archive(runtime.ledger(host), "snap") let assert Ok(session) = runtime.reload_extension(host, "snap", "/tmp", "rolling", True) let projected = prepare(host, session, history) let #(folds, rest) = list.split_while(projected, fn(input) { case input { types.User(text) -> string.starts_with(text, "[snapcompact archive page") _ -> False } }) let assert [types.User(first), ..] = folds first |> string.contains("u1 aaa") |> should.be_true // The archive's pages stand in for exactly the history its cut covers. let covered = archive.cut.users * 2 rest |> should.equal(list.drop(history, covered)) runtime.stop(host)}
pub fn transcript_tools_page_the_original_rows_test() { let #(host, _) = host() let ledger = runtime.ledger(host) let assert Ok(_) = conversation.commit_from( ledger, "snap", turns(2), conversation.Idle, Some("provider"), ) let assert Ok(found) = transcript.grep(ledger, "snap", "U2 AAA", 5, 0) found |> string.contains("\"count\":1") |> should.be_true let assert Ok(page) = transcript.read(ledger, "snap", 0, 0, 30) page |> string.contains("u1 aaa") |> should.be_true page |> string.contains("\"next_offset\":30") |> should.be_true runtime.stop(host)}
/// A forced compaction far below a large window still archives: its tail is/// a share of what the request holds, not of the window. The observation/// reports the window the budget used.pub fn forced_compaction_archives_under_a_large_window_test() { let config = snapcompact.Config(Some(1_000_000), 90, 10, 20, None, True) let assert Ok(host) = runtime.start_with_config( ":memory:", extensions.Config( [snapcompact.configured_extension(config), snapcompact.memory()], ["snapcompact", "snapcompact-memory"], ), ) let assert Ok(session) = runtime.open_session(host, "big", "/tmp") let assert Ok(prepared) = runtime.prepare_view_scoped( host, session, "model", "source", "", "", fn(_) { Error("must not summarize") }, turns(3), True, ) let assert Ok(Some(archive)) = snapcompact.load_archive(runtime.ledger(host), "big") should.be_true(archive.cut.users > 0) let assert Some(observation) = prepared.observation observation.status |> should.equal("compacted") observation.input_limit_tokens |> should.equal(Some(1_000_000)) observation.trigger_free_percent |> should.equal(Some(10)) runtime.stop(host)}499 unmodified linesExpand all500501502503504504505506507507508508509510511512510513514515516517512513518519520521522523524517525526527528529499 unmodified linesExpand all |> should.equal(Ok(None)) runtime.stop(host)}
pub fn frame_budget_fits_the_window_and_the_stack_test() {pub fn frame_budget_fits_the_window_and_the_provider_test() { let config = snapcompact.default_config() let shape = snapcompact.shape("claude-opus-5-5") let reader = fn(provider) { Some(compaction.Reader(provider, [])) } // 20% of 200k at ~1.4k tokens a frame, under the stack's cap. snapcompact.frame_budget(config, shape, "claude-opus-5-5", Some(200_000)) // 20% of 200k at ~1.4k tokens a frame, under the provider's cap. snapcompact.frame_budget(config, shape, reader("claude"), Some(200_000)) |> should.equal(27) // Claude uploads frames through the files API: only the image budget and // the archive's own cap apply. snapcompact.frame_budget(config, shape, "claude-opus-5-5", Some(1_000_000)) snapcompact.frame_budget(config, shape, reader("claude"), Some(1_000_000)) |> should.equal(80) // Inline providers also carry the 3 MB frame-data budget. snapcompact.frame_budget(config, shape, reader("anthropic"), Some(1_000_000)) |> should.equal(60) snapcompact.frame_budget(config, shape, "mystery-model", None) |> should.equal(20) snapcompact.frame_budget(config, shape, reader("umans"), None) |> should.equal(10) snapcompact.frame_budget(config, shape, None, None) |> should.equal(5) snapcompact.frame_budget( snapcompact.Config(..config, max_frames: Some(5)), shape, "qwen3.8-max", reader("alibaba"), Some(1_000_000), ) |> should.equal(5)}734 unmodified linesExpand all735736737738739740740741742741742743744745746747748272 unmodified linesExpand all734 unmodified linesExpand all case "compacted": evicted, hasEv := raw["evicted"].(float64) summary, hasSum := raw["summary"].(string) if hasEv && hasSum && evicted >= 0 && evicted <= 1000000 && len(summary) <= 60000 { strategy, _ := raw["strategy"].(string) return &StreamEvent{ Type: EventCompacted, Evicted: int(evicted), Summary: summary, Type: EventCompacted, Evicted: int(evicted), Summary: summary, Strategy: strategy, } } return nil
272 unmodified linesExpand all54 unmodified lines5556575859596061626321 unmodified lines85868788898990919293784 unmodified linesExpand all54 unmodified lines}
func TestStreamCompacted(t *testing.T) { page := formatPage(1, []any{ map[string]any{"type": "compacted", "evicted": 12, "summary": "facts"}, map[string]any{"type": "compacted", "evicted": 12, "summary": "facts", "strategy": "snapcompact"}, map[string]any{"type": "compacted", "evicted": -1, "summary": "no"}, map[string]any{"type": "compacted", "evicted": 5, "summary": strings.Repeat("x", 60001)}, })
21 unmodified lines
if len(compacted) != 1 { t.Fatalf("expected 1 compacted event, got %d", len(compacted)) } if compacted[0].Evicted != 12 || compacted[0].Summary != "facts" { if compacted[0].Evicted != 12 || compacted[0].Summary != "facts" || compacted[0].Strategy != "snapcompact" { t.Fatalf("unexpected compacted event: %+v", compacted[0]) }}
784 unmodified linesExpand all145 unmodified linesExpand all14614714814915015115215315424 unmodified lines145 unmodified linesExpand all ClientID string `json:"clientId,omitempty"` Image *ImageMetadata `json:"image,omitempty"` Evicted int `json:"evicted,omitempty"` Summary string `json:"summary,omitempty"` Strategy string `json:"strategy,omitempty"` Progress *ToolProgress `json:"progress,omitempty"` ToolName string `json:"name,omitempty"` ToolArgs map[string]any `json:"args,omitempty"` ToolResult string `json:"result,omitempty"`24 unmodified lines34 unmodified lines35363738394041424344190 unmodified linesExpand all34 unmodified lines SizeBytes int64 Mood mood ElapsedMs int64 Tools int // Strategy is the compaction strategy that produced a compacted entry. Strategy string // Pending is a message of yours the daemon has not echoed back yet. Pending pending // Seq is the newest transcript row this entry came from; 0 until a // `committed` event says which rows cover it.190 unmodified linesExpand all2126 unmodified linesExpand all212721282129213021312132213321342135549 unmodified linesExpand all2126 unmodified linesExpand all entry := HistoryEntry{ Kind: EntryCompacted, Text: evt.Summary, Evicted: evt.Evicted, Strategy: evt.Strategy, Timestamp: time.Now().UnixMilli(), } m.appendSettledEntry(entry)
549 unmodified linesExpand all602 unmodified linesExpand all603604605606607607608609610611612613614615616617618619620621622623624625626627628629630631632633141 unmodified linesExpand all602 unmodified linesExpand all action := "view" if flags.Compaction { action = "hide" } verb, noun := compactionWords(entry.Strategy) rows = []string{markChrome + r.Styles.Faint.Render(fmt.Sprintf("compaction done · %d items summarized · ctrl+k %s summary", entry.Evicted, action))} rows = []string{markChrome + r.Styles.Faint.Render(fmt.Sprintf("compaction done · %d items %s · ctrl+k %s %s", entry.Evicted, verb, action, noun))} if flags.Compaction { rows = append(rows, r.faintMarkdownRows(entry.Text, width)...) } } return strings.Join(rows, "\n")}
// compactionWords names what a strategy did with the evicted items and what// ctrl+k shows: snapcompact archives them as rendered frames, lcm folds them// into summary nodes, and rolling (or an unnamed strategy) summarizes them.func compactionWords(strategy string) (verb, noun string) { switch strategy { case "snapcompact": return "archived as frames", "archive" case "lcm": return "folded", "folds" default: return "summarized", "summary" }}
// Compact entries are one-line summaries. A run of them stacks without blank// rows so a burst of tool calls reads as one block.func Compact(entry HistoryEntry, flags DisplayFlags) bool { switch entry.Kind {141 unmodified linesExpand all