Skip to content
Closed
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
1 change: 1 addition & 0 deletions packages/sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ export type {
ProviderSettings,
SandboxFactory,
SessionData,
SessionDelta,
SessionEnv,
SessionOptions,
SessionStore,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export type {
FlueEvent,
FlueEventCallback,
SessionData,
SessionDelta,
SessionStore,
SessionEnv,
FileStat,
Expand Down
27 changes: 26 additions & 1 deletion packages/sdk/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,11 @@ export class SessionHistory {
return path.reverse();
}

/** Entry ids in storage order, used by the SDK to compute saveDelta removals. */
getEntryIds(): string[] {
return this.entries.map((entry) => entry.id);
}

/**
* Active-path entries appended after `afterLeafId` (exclusive), in order.
*
Expand Down Expand Up @@ -405,6 +410,7 @@ export class Session implements FlueSession {
private env: SessionEnv;
private store: SessionStore;
private history: SessionHistory;
private lastSavedEntryIds: Set<string>;
private createdAt: string | undefined;
private compactionSettings: CompactionSettings;
private overflowRecoveryAttempted = false;
Expand Down Expand Up @@ -439,6 +445,9 @@ export class Session implements FlueSession {
this.createdAt = options.existingData?.createdAt;

this.history = SessionHistory.fromData(options.existingData);
// Pre-existing entries are considered "already saved" — adapters that
// implement saveDelta? get only changes made after construction.
this.lastSavedEntryIds = new Set(this.history.getEntryIds());

const cc = this.config.compaction;
this.compactionSettings = {
Expand Down Expand Up @@ -1244,7 +1253,23 @@ export class Session implements FlueSession {
const now = new Date().toISOString();
const data = this.history.toData(this.metadata, this.createdAt ?? now, now);
if (!this.createdAt) this.createdAt = now;
await this.store.save(this.storageKey, data);
const currentEntryIds = new Set(data.entries.map((entry) => entry.id));
if (typeof this.store.saveDelta === 'function') {
const newEntries = data.entries.filter((entry) => !this.lastSavedEntryIds.has(entry.id));
const removedEntryIds = [...this.lastSavedEntryIds].filter((entryId) => !currentEntryIds.has(entryId));
await this.store.saveDelta(this.storageKey, {
version: data.version,
newEntries,
removedEntryIds,
leafId: data.leafId,
metadata: data.metadata,
createdAt: data.createdAt,
updatedAt: data.updatedAt,
});
} else {
await this.store.save(this.storageKey, data);
}
this.lastSavedEntryIds = currentEntryIds;
}

private async recordTaskSession(
Expand Down
56 changes: 56 additions & 0 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,10 +536,66 @@ export interface BranchSummaryEntry extends SessionEntryBase {
details?: unknown;
}

/**
* Delta passed to `SessionStore.saveDelta?`. Contains the entries appended
* since the last successful save, any entries removed from the current
* `SessionData`, and the current session header fields as full overwrites.
*
* Most history changes are append-only: compaction and branch summaries push
* new entries without mutating older ones. Overflow recovery is the exception:
* the SDK may remove a failed assistant leaf before retrying. Adapters must
* apply `removedEntryIds` before appending `newEntries` so `load()` returns
* the latest authoritative `SessionData`, not the union of all entries ever
* seen.
*/
export interface SessionDelta {
/** Session data version. Matches `SessionData.version`. */
version: SessionData['version'];
/** Entries appended since the last save call (in order). */
newEntries: SessionEntry[];
/** Entry ids removed from the current session since the last save. */
removedEntryIds: string[];
/** Current leaf id (full overwrite). */
leafId: string | null;
/** Current metadata (full overwrite — small object). */
metadata: Record<string, any>;
/** Session creation timestamp (full overwrite). */
createdAt: string;
/** Session update timestamp for this save (full overwrite). */
updatedAt: string;
}

export interface SessionStore {
save(id: string, data: SessionData): Promise<void>;
load(id: string): Promise<SessionData | null>;
delete(id: string): Promise<void>;

/**
* Optional delta hook. If implemented, it is called by live `Session`
* instances *instead of* `save()` with only the entry changes since the
* last successful save. Adapters that implement this can persist O(delta)
* per turn instead of O(history).
*
* Dispatch is checked per call via `typeof store.saveDelta === 'function'`,
* so adapters that implement both methods will only see `saveDelta` invoked
* for live session saves. `save()` remains required: Flue still uses it for
* initial empty session creation and for adapters that don't opt in.
*
* `load(id)` must still return the full `SessionData` — the adapter is
* responsible for reconstructing it from its records.
*
* When a session is resumed from `load()`, pre-existing entries are treated
* as already saved; the first `saveDelta` carries only changes made after
* construction. When `load()` returns null, Flue first writes an empty
* `SessionData` via `save()`, then later `saveDelta` calls carry entries
* appended after that empty snapshot.
*
* `newEntries.length === 0` is possible (a `save()` call with nothing to
* append) and adapters should still apply `removedEntryIds` plus the
* `leafId`/`metadata`/timestamp refresh. Empty `newEntries` is never a
* signal to delete all prior entries.
*/
saveDelta?(id: string, delta: SessionDelta): Promise<void>;
}

// ─── Options ────────────────────────────────────────────────────────────────
Expand Down
Loading