Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/session-persistence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Session Persistence & Snapshots

How MandoCode Desktop remembers — the concepts, the architecture, and where it goes next.

## The two concepts

- **History JSON is *memory*** — verbatim, heavy, machine-format, tied to one conversation's
continuation. It answers *"resume exactly where I was."*
- **A snapshot is a *knowledge artifact*** — distilled by an LLM, named by you, human-readable,
cheap to inject anywhere. It answers *"carry what we learned somewhere else."*

> **Memory doesn't transfer between minds; knowledge does.**

That line is the design rule. Anything that continues *the same* conversation (relaunch, model
switch on the same tab) should use memory. Anything that moves context *between* conversations
(another agent, another project, a fresh start) should use knowledge — a snapshot. When a new
feature needs "the agent should know about X," ask which side of the line X lives on.

## The persistence tiers

Each tier ships independently and degrades gracefully into the one below it.

| Tier | What survives | Store | Mechanism |
|------|---------------|-------|-----------|
| 1 | Workspace shape: tabs, titles, folders, models, active tab | `workspace.json` | Saved on every structural change + close; restored at launch |
| 1 | Snapshots | `snapshots.json` | Rewritten on add/remove; loaded at construction |
| 2 | The visible transcript | `transcripts/<key>.jsonl` | Append-on-write journal of every HTML block; replayed into the WebView on restore |
| 3 | The model's memory | `histories/<key>.json` | `AIService.ExportHistoryJson()` at every turn end (write-then-rename); `TryRestoreHistoryJson()` on restore |
| 3 fallback | A plain-text tail of the dialogue | `conversations/<key>.jsonl` | Armed as imported background on the next send when full fidelity can't apply |

All stores live under `%LOCALAPPDATA%\MandoCode.Desktop\`, keyed by each session's durable
`PersistKey` (a GUID that survives relaunches, unlike the process-local session Id). All writes
are best-effort and append-or-atomic: a crash loses at most the in-flight block. Caps are
enforced on the **write** side, not just at load — an app that never restarts must still have
bounded files.

### The restore cascade (per tab, in order)

1. **Full fidelity** — rehydrate the harness `ChatHistory` verbatim, tool calls included.
The agent genuinely *remembers*. Runs only **after** any saved model is re-selected,
because model selection clears history.
2. **Tail-brief** — a bounded verbatim excerpt of the dialogue rides the next send as
imported background. The agent is *briefed*, not remembering.
3. **Honest amnesia** — if a transcript was replayed but no memory exists, the model is told
exactly that, so it never has to guess about pixels it can't see.

Cleanup is symmetrical: `/clear`, closing a tab, and the startup orphan sweep remove all of a
session's files together. Cleared means cleared.

## Model switches

A switch clears the live history ("a different model mid-history is a different conversation"
was the original stance — from before any serialization existed). The offer bar now presents
both sides of the concept line:

- **Keep memory** — the pre-switch history is re-imported verbatim; the same conversation
continues under the new model. Right choice cloud↔cloud or when moving to a *bigger* model.
- **Snapshot** — the conversation is summarized into a named, portable recap. Right choice
when **downsizing** (a small local model may not fit the verbatim history) or when you want
a clean slate plus the lessons.

"Keep memory" appears only for switch offers, never for manual "Take snapshot" offers (nothing
was cleared, there is nothing to carry). If the verbatim import fails, the offer stays up and
the snapshot path remains as salvage.

## Where snapshots are left off (future building)

Snapshots persist across launches now, they record their project root, and IDs survive — but
the panel hasn't caught up: **no grouping by project, no search, and the import UX is
unchanged.** Those are polish items waiting for the snapshot library to grow now that it's
durable. Nothing broken, just room.

Other known headroom, in rough order of value:

- **Session history browser** — the per-key journals already on disk would support a
"reopen any past conversation as a new tab" picker (Claude Code's `/resume` equivalent),
not just restoring the tabs that were open at close.
- **Summarize-at-restore upgrade** — the tail-brief fallback could run `HistorySummarizer`
over the stored dialogue instead of excerpting it, trading an LLM call for better coverage
of long sessions.
- **CLI `--continue`** — `ExportHistoryJson`/`TryRestoreHistoryJson` live in the harness
precisely so the CLI can grow its own resume without new plumbing.
- **Cross-provider carry verification** — verbatim history with function-call content moving
between Ollama and cloud connectors should map cleanly through Semantic Kernel's generic
content types; it deserves a deliberate test before "Keep memory" is treated as guaranteed
across providers (the graceful fallback already handles failure).
14 changes: 11 additions & 3 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -378,14 +378,22 @@
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<FontIcon Glyph="&#xE722;" FontSize="14" VerticalAlignment="Center"
Foreground="{StaticResource MandoAccentBrush}"/>
<TextBlock x:Name="SnapshotNotifyText" Grid.Column="1" VerticalAlignment="Center"
FontSize="13" TextTrimming="CharacterEllipsis"/>
<Button Grid.Column="2" Content="Create" Padding="12,4"
Click="SnapshotNotifyCreate_Click" Style="{StaticResource AccentButtonStyle}"/>
<Button Grid.Column="3" Click="SnapshotOfferDismiss_Click" Padding="6"
<!-- Model switches only (hidden for manual snapshot offers): continue the
SAME conversation on the new model, verbatim — memory, not a recap. -->
<Button x:Name="SnapshotKeepMemoryButton" Grid.Column="2" Content="Keep memory"
Padding="12,4" Visibility="Collapsed" Click="SnapshotKeepMemory_Click"
Style="{StaticResource AccentButtonStyle}"
ToolTipService.ToolTip="Continue this conversation on the new model with full memory — nothing summarized, nothing lost"/>
<Button Grid.Column="3" Content="Snapshot" Padding="12,4"
Click="SnapshotNotifyCreate_Click"
ToolTipService.ToolTip="Save a summarized, portable recap you can import into any agent"/>
<Button Grid.Column="4" Click="SnapshotOfferDismiss_Click" Padding="6"
Background="Transparent" BorderThickness="0"
ToolTipService.ToolTip="Dismiss — the conversation won't be saved">
<FontIcon Glyph="&#xE711;" FontSize="12"/>
Expand Down
139 changes: 137 additions & 2 deletions src/MandoCode.Desktop/Controls/ChatTabView.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,12 @@ public async Task InitializeAsync()

core.Settings.AreDefaultContextMenusEnabled = true;
core.Settings.AreDevToolsEnabled = true; // F12 in the transcript
core.NavigationCompleted += (_, _) =>
core.NavigationCompleted += async (_, _) =>
{
// Replay the journaled transcript FIRST (while _webViewReady is still false,
// so live blocks keep queueing) — restored history must precede this
// launch's boot output.
await RestoreJournaledTranscriptAsync();
_webViewReady = true;
while (_pendingHtml.Count > 0) AppendHtml(_pendingHtml.Dequeue());
};
Expand Down Expand Up @@ -348,6 +352,120 @@ public void HandleEscape()
// Transcript
// ============================================================

private bool _journalRestored;

/// <summary>Replays this session's journaled transcript into the fresh WebView — via
/// ExecuteScript directly, NOT through TranscriptWriter (that would re-journal every
/// block). Chunked so a long history is a few script calls, not a thousand.</summary>
private async Task RestoreJournaledTranscriptAsync()
{
if (_journalRestored) return;
_journalRestored = true;
try
{
var blocks = TranscriptJournal.Load(Session.PersistKey)
.Where(b => !TranscriptHtmlBuilder.IsEphemeralStatus(b))
.ToList();
if (blocks.Count == 0) return;

var chunk = new System.Text.StringBuilder();
foreach (var block in blocks)
{
chunk.Append(block);
if (chunk.Length > 400_000)
{
await AppendRawAsync(chunk.ToString());
chunk.Clear();
}
}
if (chunk.Length > 0) await AppendRawAsync(chunk.ToString());

// Divider goes through AppendRawAsync too — journaling it would stack one
// divider per relaunch. Memory restore happens LATER (RestoreConversationMemoryAsync,
// called by MainWindow after any saved model is re-selected — a model switch clears
// history, so restoring memory here would risk it being wiped moments later).
_replayedBlockCount = blocks.Count;
await AppendRawAsync(_html.Dim("— restored from your previous session —"));
}
catch { /* a failed replay must never block a fresh conversation */ }
}

private int _replayedBlockCount;

/// <summary>
/// Gives the restored session its memory back, best fidelity first. Called by MainWindow
/// AFTER the tab's harness is initialized and any saved model re-selected. Cascade:
/// 1) full-fidelity harness history (the agent genuinely remembers, tool calls included);
/// 2) plain-text tail armed as imported background (briefed, not remembering);
/// 3) an honest amnesia note, so the model never has to guess about the replayed pixels.
/// Fresh tabs have none of the files and fall straight through as a no-op.
/// </summary>
public async Task RestoreConversationMemoryAsync()
{
try
{
// 1) Full fidelity: rehydrate the harness's ChatHistory verbatim.
var historyJson = SessionHistoryStore.Load(Session.PersistKey);
if (historyJson != null)
{
var restored = await Task.Run(() => Session.Ai.TryRestoreHistoryJson(historyJson));
if (restored > 0)
{
await AppendRawAsync(_html.Dim(
$"Conversation memory restored — the agent remembers this session ({restored} messages)."));
return;
}
}

// 2) Tail-brief: bounded verbatim excerpt rides the next send as imported background.
var turns = ConversationLog.Load(Session.PersistKey);
if (turns.Count > 0)
{
const int budget = 12_000;
var picked = new List<ConversationTurn>();
var used = 0;
for (var i = turns.Count - 1; i >= 0; i--)
{
if (picked.Count > 0 && used + turns[i].T.Length > budget) break;
picked.Add(turns[i]);
used += turns[i].T.Length;
}
picked.Reverse();

var sb = new System.Text.StringBuilder();
if (picked.Count < turns.Count)
sb.Append($"(Older turns omitted — this is the most recent {picked.Count} of {turns.Count}.)\n\n");
foreach (var turn in picked)
sb.Append(turn.R == "u" ? "User: " : "Assistant: ").Append(turn.T).Append("\n\n");

_controller.ArmRestoredConversation(
"From \"your previous session in this tab\" (verbatim excerpt, not a recap):\n" +
sb.ToString().TrimEnd());
await AppendRawAsync(_html.Dim(
"Context re-armed — the agent will be briefed on this conversation with your next message."));
return;
}

// 3) Transcript was replayed but no memory of any kind exists — say so to the model.
if (_replayedBlockCount > 0)
_controller.NoteWorkspaceEvent(
"This tab was restored from a previous session. The transcript the user sees above is a replay " +
"for their benefit; it is NOT in your context and you have no memory of it. If the user refers " +
"to earlier work, say so honestly and re-read files instead of guessing.");
}
catch { /* memory restore is best-effort; a fresh conversation always works */ }
}

private async Task AppendRawAsync(string html)
{
try
{
await TranscriptView.CoreWebView2.ExecuteScriptAsync(
$"window.__append({JsonSerializer.Serialize(html)})");
}
catch { }
}

private async void AppendHtml(string html)
{
if (_shutDown) return;
Expand Down Expand Up @@ -397,7 +515,12 @@ private void RefreshSnapshotOffer()
}

// Stage 1: notification bar. Non-blocking — the user can ignore it and keep prompting.
SnapshotNotifyText.Text = $"Snapshot available — save the {offer.OriginModel} conversation.";
// "Keep memory" only appears when a switch actually cleared a conversation.
SnapshotKeepMemoryButton.Visibility = _controller.CanCarryMemory
? Visibility.Visible : Visibility.Collapsed;
SnapshotNotifyText.Text = _controller.CanCarryMemory
? $"Keep the {offer.OriginModel} conversation going, or save it as a snapshot?"
: $"Snapshot available — save the {offer.OriginModel} conversation.";
SnapshotNotifyBar.Visibility = Visibility.Visible;
SnapshotOfferCard.Visibility = Visibility.Collapsed;
SnapshotOfferRoot.Visibility = Visibility.Visible;
Expand Down Expand Up @@ -534,6 +657,12 @@ private void SetSnapshotBusy(bool busy)
private void SnapshotOfferDismiss_Click(object sender, RoutedEventArgs e)
=> _controller.DismissSnapshotOffer();

/// <summary>"Keep memory": verbatim continuation on the new model. On success the offer
/// clears itself (SnapshotOfferChanged → RefreshSnapshotOffer); on failure the bar stays
/// so Snapshot remains available as the salvage path.</summary>
private void SnapshotKeepMemory_Click(object sender, RoutedEventArgs e)
=> _controller.TryCarryMemoryAcrossSwitch();

/// <summary>Saves this tab's transcript as a standalone HTML page. Shared by the header save
/// button and the tab's options menu.</summary>
public async Task ExportTranscriptAsync()
Expand Down Expand Up @@ -628,6 +757,12 @@ private void UpdateBusy(bool busy, string? activity)
// detecting OUTSIDE-the-conversation changes before the next send.
_wsTracker.MarkCapturePending();
RefreshBranchChip(force: true);

// Persist the model's full memory as of this turn (tier-3 full fidelity).
// Off-thread: serialization of a long history shouldn't touch UI latency.
var key = Session.PersistKey;
var ai = Session.Ai;
_ = Task.Run(() => SessionHistoryStore.Save(key, ai.ExportHistoryJson()));
}
}

Expand Down
Loading
Loading