diff --git a/super-legal-mcp-refactored/CHANGELOG.md b/super-legal-mcp-refactored/CHANGELOG.md
index 6b77626c8..fb5aae73f 100644
--- a/super-legal-mcp-refactored/CHANGELOG.md
+++ b/super-legal-mcp-refactored/CHANGELOG.md
@@ -37,6 +37,64 @@ The product clears $400K/month per deployment and is sold to investment banks, p
- `super-legal-mcp-refactored/test/react-frontend/styles.css` — 6 selector blocks
- `super-legal-mcp-refactored/test/react-frontend/app.js` — `renderMarkdown` + new `stripEmojis` helper
+---
+
+## [6.8.0] - 2026-04-27
+
+### Added — Full-fidelity transcript persistence + replay (PR #88)
+
+Captures every SSE event flowing through `ctx.send()` into a new `transcript_events` table so session reload faithfully replays the original live experience — orchestrator narration, inline tool blocks, thinking blocks, system status updates. Pre-v6.8.0 sessions remain readable via the existing reconstruction view (banner notes the limitation).
+
+**Default OFF** via `TRANSCRIPT_DB_PERSISTENCE=false` in `flags.env`. Phase 5 flips the flag after Phases 1-4 soak.
+
+#### New table — `transcript_events` (`migrations/012_transcript-events.up.sql`)
+
+Single replay-path index (filter/cleanup indexes deliberately omitted — cascade FK handles GDPR Art. 17 purge atomically). DDL also wired into `postgres.js:ensureHookSchema()` for runtime application.
+
+#### Buffered batch insert (`src/server/streamContext.js`)
+
+- 50-event buffer / 2s flush / final flush on `ctx.end()`
+- Multi-row `INSERT ... VALUES (...), (...), ...` collapses ~5,000 individual writes to ~100 batched inserts per session
+- Lazy `dbSessionId` resolution: events buffer with `sessionId=null` until the sessions row is created (~1-3s in), then back-fill at flush time. Zero events lost
+- Concurrent-flush guard via `transcriptFlushInProgress`
+- 3-strike circuit breaker (`TRANSCRIPT_MAX_FLUSH_FAILURES`) prevents log flooding on persistent DB outages
+- Final flush registered with `backgroundTasks` Set so `gracefulShutdown` Phase 2.5 waits for it before `pool.end()`
+
+#### Read endpoint (`src/server/dbFrontendRouter.js`)
+
+`GET /api/db/sessions/:sessionKey/transcript` returns ordered events for replay. Wrapped with `transcriptAccessAudit` middleware (mirrors `kgAccessAudit` Wave 4 pattern) for EU AI Act Article 12 compliance — transcripts may contain attorney-client privileged content.
+
+Returns `{status: 'no_transcript'}` for sessions predating v6.8.0 so frontend falls back gracefully.
+
+#### Frontend replay (`test/react-frontend/app.js`)
+
+- New `replayMode` flag suppresses auto-scroll during replay (prevents 5,000-event DOM thrash)
+- New `replayTranscript(sessionKey)` fetches stored events and dispatches each through existing `handleStreamEvent()` — same code path as live streaming, full UI fidelity
+- **Delta batching**: accumulates consecutive `delta` events into a single `appendText()` call, reducing markdown re-renders from ~2,000 to ~30-50 per session. Replay completes in <100ms typical
+- `__loadSession` clears stale transcript state on entry; falls back to existing reconstruction view when transcript fetch fails or returns `no_transcript`
+
+#### Pool sizing
+
+`PG_POOL_MAX` default bumped 10 → 15 to provide ~33% burst margin during simultaneous live stream + reconciliation rebuild + transcript flush. `statement_timeout` deliberately kept at 120s (multi-row INSERTs run <500ms typical).
+
+#### Out-of-scope (transcripts are UX data, not legal evidence)
+
+- ❌ Embeddings (no `report_embeddings` integration)
+- ❌ KG nodes / edges (transcripts not part of the knowledge graph)
+- ❌ Provenance / `source_writes` (not raw source data)
+- ❌ Citation extraction
+- ❌ Content search GIN index (defer to v6.9 if needed)
+
+#### Storage cost
+
+~700KB-1MB per session (~5,000 SSE events at ~150-200 bytes each). 10K sessions ≈ 7-10 GB. Cloud SQL ~$1-2/month at GCP storage pricing.
+
+**Files**: 8 modified/created, ~360 lines added. Break risk 0-2/10 per phase. See PR #88.
+
+**Rollback**: `TRANSCRIPT_DB_PERSISTENCE=false` — captures stop, existing rows remain queryable, frontend continues to consume them on reload.
+
+---
+
## [6.7.0] - 2026-04-26
### Added — Auto-reconciliation loop for KG + artifacts (PR #87)
diff --git a/super-legal-mcp-refactored/docs/feature-flags.md b/super-legal-mcp-refactored/docs/feature-flags.md
index 57a2d43eb..14683eeee 100644
--- a/super-legal-mcp-refactored/docs/feature-flags.md
+++ b/super-legal-mcp-refactored/docs/feature-flags.md
@@ -50,6 +50,7 @@ All feature flags are environment-variable-controlled via the `envBool()` helper
| 33 | [`ACCESS_AUDIT`](#33-access_audit) | `false` | Active | Observability |
| 34 | [`GCS_TIERING`](#34-gcs_tiering) | `false` | Active | Storage |
| 35 | [`SESSION_RECONCILIATION`](#35-session_reconciliation) | `false` | Active | Observability |
+| 36 | [`TRANSCRIPT_DB_PERSISTENCE`](#36-transcript_db_persistence) | `true` (active) | Active | Observability |
---
@@ -1183,6 +1184,32 @@ Hourly in-process reconciliation loop that detects sessions whose post-pipeline
---
+### 36. TRANSCRIPT_DB_PERSISTENCE
+
+**Status**: Active (v6.8.0). Default `false`.
+
+Captures every SSE event flowing through `ctx.send()` in `streamContext.js` into the `transcript_events` table so a session reload faithfully replays the original live experience — orchestrator narration, inline tool blocks, thinking blocks, system status updates. Without this, session reload only shows reconstructed agent cards + final reports (the orchestrator's reasoning trail is lost).
+
+**When enabled**: each session writes ~5,000 events / ~700KB-1MB to `transcript_events`. Buffered batch INSERTs (50 events / 2s flush / final flush on `ctx.end()`) collapse the write volume to ~100 multi-row INSERTs per session.
+
+**When disabled** (default): zero behavior change. Schema columns/indexes remain (additive). Pre-v6.8.0 sessions that never had transcripts continue to use the existing reconstruction view — frontend shows a fallback banner.
+
+**Files**:
+- Capture: `src/server/streamContext.js` (`_enqueueTranscriptEvent`, `_flushTranscriptBuffer`)
+- Schema: `src/db/postgres.js` (`TRANSCRIPT_EVENTS_DDL`), `migrations/012_transcript-events.up.sql`
+- Read: `src/server/dbFrontendRouter.js` (`GET /api/db/sessions/:sessionKey/transcript` with `transcriptAccessAudit` middleware)
+- Replay: `test/react-frontend/app.js` (`replayTranscript()`, `replayMode` flag, delta batching)
+
+**Compliance**: transcripts may contain attorney-client privileged content. Access logged via `transcriptAccessAudit` middleware (Wave 4 EU AI Act Article 12 pattern). Right-to-erasure handled via `ON DELETE CASCADE` from `sessions(id)` — no separate retention machinery.
+
+**Out-of-scope** (transcripts are UX data, not legal evidence): no embeddings, no KG nodes, no `source_writes` provenance, no citation extraction, no GIN content search. Single replay-path index only.
+
+**Pool sizing**: enabling this flag requires `PG_POOL_MAX ≥ 15` (default bumped 10→15 in v6.8.0) for ~33% burst margin during simultaneous live stream + reconciliation rebuild + transcript flush.
+
+**Rollback**: `TRANSCRIPT_DB_PERSISTENCE=false` — captures stop instantly. Already-captured rows remain queryable (frontend continues to consume them on reload). Schema columns/indexes remain (additive — safe to leave).
+
+---
+
## Dead Code Flags
These are exported from `featureFlags.js` but never consumed at runtime:
diff --git a/super-legal-mcp-refactored/flags.env b/super-legal-mcp-refactored/flags.env
index 3ef743ed5..5fe286e5a 100644
--- a/super-legal-mcp-refactored/flags.env
+++ b/super-legal-mcp-refactored/flags.env
@@ -32,6 +32,7 @@ WAL_ENABLED=true
ACCESS_AUDIT=true
GCS_TIERING=true
SESSION_RECONCILIATION=true
+TRANSCRIPT_DB_PERSISTENCE=true
CANARY_PCT=100
PRESERVE_GRACE_PERIOD=0
SKILLS_ENABLED=false
diff --git a/super-legal-mcp-refactored/migrations/012_transcript-events.down.sql b/super-legal-mcp-refactored/migrations/012_transcript-events.down.sql
new file mode 100644
index 000000000..31cb3072f
--- /dev/null
+++ b/super-legal-mcp-refactored/migrations/012_transcript-events.down.sql
@@ -0,0 +1,7 @@
+-- 012_transcript-events.down.sql
+-- v6.8.0 rollback companion.
+-- Drops the transcript_events table + its index. Cascade FK on the table
+-- means no other tables hold references; clean to drop.
+
+DROP INDEX IF EXISTS idx_transcript_session_seq;
+DROP TABLE IF EXISTS transcript_events;
diff --git a/super-legal-mcp-refactored/migrations/012_transcript-events.up.sql b/super-legal-mcp-refactored/migrations/012_transcript-events.up.sql
new file mode 100644
index 000000000..de47d8486
--- /dev/null
+++ b/super-legal-mcp-refactored/migrations/012_transcript-events.up.sql
@@ -0,0 +1,25 @@
+-- 012_transcript-events.up.sql
+-- v6.8.0: Full-fidelity transcript event log.
+-- Captures every SSE event flowing through ctx.send() (assistant_text, tool_call,
+-- thinking_block, agent_progress, hook_event, delta, etc.) so a session reload
+-- can faithfully replay the original live experience.
+--
+-- Single replay-path index. Deliberately NOT integrated with embeddings/KG/
+-- provenance: transcripts are user-visible UX data, not legal evidence.
+-- Cascade delete via FK inherits Wave 3 retention + GDPR Art. 17 atomically.
+
+CREATE TABLE IF NOT EXISTS transcript_events (
+ id BIGSERIAL PRIMARY KEY,
+ session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ session_key VARCHAR(75) NOT NULL,
+ sequence_number BIGINT NOT NULL,
+ event_type VARCHAR(64) NOT NULL,
+ event_data JSONB NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+);
+
+-- Single index covering the only query path: ordered replay by session.
+-- Filtered-by-event-type and time-range cleanup queries deliberately omitted
+-- (cascade FK handles cleanup; replay is full-transcript-in-order).
+CREATE INDEX IF NOT EXISTS idx_transcript_session_seq
+ ON transcript_events(session_id, sequence_number ASC);
diff --git a/super-legal-mcp-refactored/src/config/featureFlags.js b/super-legal-mcp-refactored/src/config/featureFlags.js
index 49665f636..da7703848 100644
--- a/super-legal-mcp-refactored/src/config/featureFlags.js
+++ b/super-legal-mcp-refactored/src/config/featureFlags.js
@@ -125,6 +125,14 @@ export const featureFlags = {
// always populate kg_status/artifacts_status columns; only the loop is gated.
// Rollback: SESSION_RECONCILIATION=false (loop stops; columns/indexes remain).
SESSION_RECONCILIATION: envBool(process.env.SESSION_RECONCILIATION, false),
+ // v6.8.0: full-fidelity transcript persistence — captures every SSE event
+ // through ctx.send() into the transcript_events table for faithful replay
+ // on session reload. Buffered batch inserts (50 events / 2s flush) collapse
+ // ~5,000 individual writes to ~100 multi-row INSERTs per session.
+ // Inert when false; schema is purely additive (table created either way).
+ // Rollback: TRANSCRIPT_DB_PERSISTENCE=false (captures stop; existing rows
+ // remain queryable; frontend continues to consume them on reload).
+ TRANSCRIPT_DB_PERSISTENCE: envBool(process.env.TRANSCRIPT_DB_PERSISTENCE, false),
};
// Model constants for selection logic
diff --git a/super-legal-mcp-refactored/src/db/postgres.js b/super-legal-mcp-refactored/src/db/postgres.js
index 8d4846b16..05fe46aa2 100644
--- a/super-legal-mcp-refactored/src/db/postgres.js
+++ b/super-legal-mcp-refactored/src/db/postgres.js
@@ -8,7 +8,7 @@ export function getPool() {
if (!connectionString) return null;
pool = new Pool({
connectionString,
- max: Number(process.env.PG_POOL_MAX || 10),
+ max: Number(process.env.PG_POOL_MAX || 15),
idleTimeoutMillis: 600_000,
connectionTimeoutMillis: 10_000,
statement_timeout: 120_000,
@@ -565,6 +565,29 @@ const BACKFILL_SESSION_STATUS_SEMANTICS_DDL = `
AND updated_at < NOW() - INTERVAL '4 hours';
`;
+// v6.8.0: full-fidelity transcript event log.
+// Stores every SSE event passed through ctx.send() in agentStreamHandler.js,
+// so a session reload can faithfully replay the original live experience
+// (orchestrator narration, tool blocks, thinking blocks, status updates, etc.)
+// — content the existing reports/KG/audit_log surfaces don't capture.
+//
+// Single replay-path index. Deliberately NOT integrated with embeddings/KG/
+// provenance: transcripts are user-visible UX data, not legal evidence.
+// Cascade delete via FK inherits Wave 3 retention + GDPR Art. 17 atomically.
+const TRANSCRIPT_EVENTS_DDL = `
+ CREATE TABLE IF NOT EXISTS transcript_events (
+ id BIGSERIAL PRIMARY KEY,
+ session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
+ session_key VARCHAR(75) NOT NULL,
+ sequence_number BIGINT NOT NULL,
+ event_type VARCHAR(64) NOT NULL,
+ event_data JSONB NOT NULL,
+ created_at TIMESTAMPTZ DEFAULT NOW()
+ );
+ CREATE INDEX IF NOT EXISTS idx_transcript_session_seq
+ ON transcript_events(session_id, sequence_number ASC);
+`;
+
const EMBEDDING_EXTENSION_DDL = `CREATE EXTENSION IF NOT EXISTS vector;`;
const EMBEDDING_SCHEMA_DDL = `
@@ -911,6 +934,10 @@ export async function ensureHookSchema() {
// (<900KB report content) as 'error', and stale in_progress (>4h) as
// 'abandoned'. Idempotent: only matches rows that meet the criteria.
await p.query(BACKFILL_SESSION_STATUS_SEMANTICS_DDL);
+ // v6.8.0: full-fidelity transcript event log table for live SSE event
+ // capture + replay on session reload. Default-OFF feature flag gates writes;
+ // schema is purely additive and safe to leave even if flag never flips.
+ await p.query(TRANSCRIPT_EVENTS_DDL);
}
/**
diff --git a/super-legal-mcp-refactored/src/server/dbFrontendRouter.js b/super-legal-mcp-refactored/src/server/dbFrontendRouter.js
index 5e88c32b8..46c8df89d 100644
--- a/super-legal-mcp-refactored/src/server/dbFrontendRouter.js
+++ b/super-legal-mcp-refactored/src/server/dbFrontendRouter.js
@@ -956,6 +956,51 @@ export function createDbFrontendRouter() {
// Wave 4: KG-specific access audit (EU AI Act Article 12)
const kgAccessAudit = createAccessAuditMiddleware('knowledge_graph');
+ // v6.8.0: transcript-specific access audit (also EU AI Act Article 12 — transcripts
+ // contain user prompts and may include attorney-client privileged content).
+ const transcriptAccessAudit = createAccessAuditMiddleware('transcript');
+
+ // ─── GET /api/db/sessions/:sessionKey/transcript — full SSE event log for replay ───
+ // Returns ordered list of every event that flowed through ctx.send() during the
+ // session, enabling faithful replay on session reload.
+ // - 200 + status='available' + events[] when rows exist
+ // - 200 + status='no_transcript' for sessions predating v6.8.0 (frontend falls
+ // back to existing reconstruction view)
+ // - 400 on malformed session_key
+ // - 503 when DB not configured
+ // Read-only; no writes. Access logged via transcriptAccessAudit middleware.
+ router.get('/api/db/sessions/:sessionKey/transcript', transcriptAccessAudit, async (req, res) => {
+ const pool = getPool();
+ if (!pool) return res.status(503).json({ error: 'Database not configured' });
+ const { sessionKey } = req.params;
+ if (!SESSION_KEY_RE.test(sessionKey)) {
+ return res.status(400).json({ error: 'Invalid session key format' });
+ }
+ try {
+ const result = await pool.query(`
+ SELECT sequence_number, event_type, event_data, created_at
+ FROM transcript_events
+ WHERE session_key = $1
+ ORDER BY sequence_number ASC`, [sessionKey]);
+ if (result.rows.length === 0) {
+ return res.json({
+ status: 'no_transcript',
+ message: 'Session predates transcript persistence (v6.8.0) or transcripts disabled',
+ events: [],
+ total: 0,
+ });
+ }
+ return res.json({
+ status: 'available',
+ events: result.rows,
+ total: result.rows.length,
+ });
+ } catch (err) {
+ console.error('[Transcript] Fetch failed:', err.message);
+ return res.status(500).json({ error: 'Transcript query failed' });
+ }
+ });
+
// GET /api/db/sessions/:sessionKey/kg/graph — Full graph in force-graph {nodes, links} format
router.get('/api/db/sessions/:sessionKey/kg/graph', kgAccessAudit, async (req, res) => {
const pool = getPool();
diff --git a/super-legal-mcp-refactored/src/server/streamContext.js b/super-legal-mcp-refactored/src/server/streamContext.js
index 20ab60ca9..c60188622 100644
--- a/super-legal-mcp-refactored/src/server/streamContext.js
+++ b/super-legal-mcp-refactored/src/server/streamContext.js
@@ -8,6 +8,21 @@
* refactoring phases that wire it in.
*/
+import { featureFlags } from '../config/featureFlags.js';
+import { getPool } from '../db/postgres.js';
+import { backgroundTasks } from '../utils/hookDBBridge.js';
+
+// ---------------------------------------------------------------------------
+// v6.8.0: Transcript persistence — buffered batch insert constants.
+// Buffer is per-context. Flushes whenever any of:
+// - buffer reaches 50 events,
+// - 2 seconds elapse since last flush, or
+// - stream ends (final flush in end()).
+// ---------------------------------------------------------------------------
+const TRANSCRIPT_BUFFER_FLUSH_SIZE = 50;
+const TRANSCRIPT_BUFFER_FLUSH_INTERVAL_MS = 2000;
+const TRANSCRIPT_MAX_FLUSH_FAILURES = 3;
+
// ---------------------------------------------------------------------------
// SessionContext
// ---------------------------------------------------------------------------
@@ -67,6 +82,17 @@ export class SessionContext {
this.sessionInfo = null;
this.p0Summary = null;
this.documents = [];
+
+ // ── v6.8.0: Transcript event capture (flag-gated) ─────────────────
+ // Buffer accumulates SSE events from send(); flushes via batch INSERT
+ // into transcript_events. dbSessionId lazy-resolved at first flush
+ // (sessions row may not exist when early events fire).
+ this.transcriptSequence = 0;
+ this.dbSessionId = null;
+ this.transcriptBuffer = [];
+ this.transcriptFlushTimer = null;
+ this.transcriptFlushInProgress = false;
+ this.transcriptFlushFailures = 0;
}
// ── send() ────────────────────────────────────────────────────────────
@@ -84,11 +110,130 @@ export class SessionContext {
if (this.res.destroyed || this.ended) return false;
// Backpressure: drop non-critical events when buffer > 1MB
if (!critical && this.res.writableLength > 1_048_576) return false;
+ let wrote;
try {
- return this.res.write(`data: ${JSON.stringify(obj)}\n\n`);
+ wrote = this.res.write(`data: ${JSON.stringify(obj)}\n\n`);
} catch {
return false;
}
+
+ // v6.8.0: enqueue for transcript persistence after the client received it.
+ // Buffer-until-resolved: events captured even before dbSessionId lookup
+ // succeeds; back-filled at flush time. Drops only on >3 consecutive flush
+ // failures (DB outage). Heartbeats use res.write() directly (line ~108)
+ // and are auto-excluded — they never pass through send().
+ if (featureFlags.TRANSCRIPT_DB_PERSISTENCE) {
+ try {
+ this._enqueueTranscriptEvent(obj);
+ } catch (err) {
+ // Never break the stream on persistence-side errors
+ console.warn('[Transcript] Enqueue failed (non-fatal):', err.message);
+ }
+ }
+
+ return wrote;
+ }
+
+ // ── v6.8.0: Transcript persistence helpers ──────────────────────────
+ _enqueueTranscriptEvent(obj) {
+ const seqNum = this.transcriptSequence++;
+ this.transcriptBuffer.push({
+ sessionId: this.dbSessionId, // may be null on early events
+ sessionKey: this.sessionDir,
+ sequenceNumber: seqNum,
+ eventType: obj?.type || 'unknown',
+ eventData: obj, // full payload — no filtering, no truncation
+ });
+
+ if (this.transcriptBuffer.length >= TRANSCRIPT_BUFFER_FLUSH_SIZE) {
+ this._flushTranscriptBuffer().catch(() => {});
+ } else if (!this.transcriptFlushTimer) {
+ this.transcriptFlushTimer = setTimeout(() => {
+ this.transcriptFlushTimer = null;
+ this._flushTranscriptBuffer().catch(() => {});
+ }, TRANSCRIPT_BUFFER_FLUSH_INTERVAL_MS);
+ if (this.transcriptFlushTimer.unref) this.transcriptFlushTimer.unref();
+ }
+ }
+
+ async _flushTranscriptBuffer() {
+ if (this.transcriptFlushInProgress) return;
+ if (this.transcriptBuffer.length === 0) return;
+ if (this.transcriptFlushFailures >= TRANSCRIPT_MAX_FLUSH_FAILURES) {
+ // Persistent DB issue — stop trying for this session, drop in-flight buffer
+ this.transcriptBuffer = [];
+ return;
+ }
+
+ const pool = getPool();
+ if (!pool) {
+ this.transcriptBuffer = [];
+ return;
+ }
+
+ // Lazy session_id resolution. The sessions row may not exist yet on the
+ // very first events (system_init, prompt_enhancement_status fire <5ms;
+ // sessions INSERT typically happens at first SubagentStart, ~1-3s in).
+ // If still null, re-arm timer and retry on next tick — events stay
+ // safely buffered in the meantime.
+ if (!this.dbSessionId) {
+ try {
+ const r = await pool.query(
+ `SELECT id FROM sessions WHERE session_key = $1 LIMIT 1`,
+ [this.sessionDir]);
+ this.dbSessionId = r.rows[0]?.id || null;
+ } catch {}
+ if (!this.dbSessionId) {
+ if (!this.transcriptFlushTimer) {
+ this.transcriptFlushTimer = setTimeout(() => {
+ this.transcriptFlushTimer = null;
+ this._flushTranscriptBuffer().catch(() => {});
+ }, TRANSCRIPT_BUFFER_FLUSH_INTERVAL_MS);
+ if (this.transcriptFlushTimer.unref) this.transcriptFlushTimer.unref();
+ }
+ return;
+ }
+ }
+
+ this.transcriptFlushInProgress = true;
+ if (this.transcriptFlushTimer) {
+ clearTimeout(this.transcriptFlushTimer);
+ this.transcriptFlushTimer = null;
+ }
+
+ // Snapshot + clear buffer atomically; back-fill any null sessionIds
+ // with the now-resolved dbSessionId before INSERT.
+ const batch = this.transcriptBuffer.splice(0).map(e => ({
+ ...e,
+ sessionId: e.sessionId || this.dbSessionId,
+ }));
+
+ const placeholders = batch.map((_, i) => {
+ const o = i * 5;
+ return `($${o+1}, $${o+2}, $${o+3}, $${o+4}, $${o+5})`;
+ }).join(', ');
+ const params = batch.flatMap(e => [
+ e.sessionId, e.sessionKey, e.sequenceNumber, e.eventType, JSON.stringify(e.eventData)
+ ]);
+
+ try {
+ await pool.query(
+ `INSERT INTO transcript_events
+ (session_id, session_key, sequence_number, event_type, event_data)
+ VALUES ${placeholders}`,
+ params
+ );
+ this.transcriptFlushFailures = 0;
+ } catch (err) {
+ this.transcriptFlushFailures++;
+ console.warn(
+ `[Transcript] Flush failed (${batch.length} events lost, ` +
+ `failure ${this.transcriptFlushFailures}/${TRANSCRIPT_MAX_FLUSH_FAILURES}):`,
+ err.message
+ );
+ } finally {
+ this.transcriptFlushInProgress = false;
+ }
}
// ── startHeartbeat() ──────────────────────────────────────────────────
@@ -117,12 +262,31 @@ export class SessionContext {
}
// ── end() ─────────────────────────────────────────────────────────────
- /** Idempotent stream termination. */
+ /**
+ * Idempotent stream termination.
+ * v6.8.0: schedules a final transcript flush (fire-and-forget) before
+ * closing the response so the in-flight buffer lands. Worst case if the
+ * container crashes between scheduling and flush completion: ≤2s of events
+ * lost (matches the documented buffering interval).
+ */
end() {
if (this.ended) return;
this.ended = true;
clearInterval(this.heartbeat);
clearTimeout(this.sessionTimeout);
+ if (this.transcriptFlushTimer) {
+ clearTimeout(this.transcriptFlushTimer);
+ this.transcriptFlushTimer = null;
+ }
+ // Final transcript flush — register with backgroundTasks so gracefulShutdown
+ // Phase 2.5 (v6.6.0) waits for it before pool.end(). Without registration,
+ // a SIGTERM right after stream end could lose the last in-flight batch.
+ // Pattern mirrors agentStreamHandler's artifactPromise registration.
+ if (featureFlags.TRANSCRIPT_DB_PERSISTENCE && this.transcriptBuffer.length > 0) {
+ const flushPromise = this._flushTranscriptBuffer().catch(() => {});
+ backgroundTasks.add(flushPromise);
+ flushPromise.finally(() => backgroundTasks.delete(flushPromise));
+ }
try { this.res.end(); } catch {}
try { this._onEnd(); } catch (err) {
console.warn('[Stream] onEnd callback error (absorbed):', err);
diff --git a/super-legal-mcp-refactored/test/react-frontend/app.js b/super-legal-mcp-refactored/test/react-frontend/app.js
index f983dd825..7a70f7b59 100644
--- a/super-legal-mcp-refactored/test/react-frontend/app.js
+++ b/super-legal-mcp-refactored/test/react-frontend/app.js
@@ -136,6 +136,9 @@
let currentBubble = null;
let textBuffer = '';
let thinkingEl = null;
+ // v6.8.0: when true, replay-driven event dispatch should suppress live-only
+ // side effects (auto-scroll, animation thrash). See replayTranscript().
+ let replayMode = false;
let eventLog = [];
let streamStats = { turns: 0, tools: 0, webSearches: 0, inputTok: 0, outputTok: 0, cacheTok: 0 };
let healthTimer = null;
@@ -636,7 +639,8 @@
const pin = isNearBottom(transcriptScroller);
transcript.appendChild(el);
- if (pin) scrollToBottom(transcriptScroller);
+ // v6.8.0: skip auto-scroll on replay so 5,000-event reconstruction doesn't thrash
+ if (pin && !replayMode) scrollToBottom(transcriptScroller);
return el;
}
@@ -659,7 +663,8 @@
textBuffer += decodeEntities(text);
const html = renderMarkdown(textBuffer);
currentBubble.innerHTML = html || esc(textBuffer);
- if (pin) scrollToBottom(transcriptScroller);
+ // v6.8.0: skip auto-scroll on replay so per-token deltas don't thrash
+ if (pin && !replayMode) scrollToBottom(transcriptScroller);
}
// ── Streaming State ─────────────────────────────────────────
@@ -991,6 +996,12 @@
el.classList.toggle('sh-active', el.dataset.sessionKey === sessionKey)
);
+ // v6.8.0: clear stale transcript state before loading another session.
+ // Without this, previous session's bubbles remain mixed with the new one.
+ if (transcript) transcript.innerHTML = '';
+ currentBubble = null;
+ textBuffer = '';
+
// Fetch session detail + timeline from DB (with cache)
try {
const cached = getCachedSession(sessionKey);
@@ -1008,23 +1019,33 @@
cacheSessionData(sessionKey, { detail, tlData });
}
- // Reconstruct phase progress from agent states
+ // Reconstruct phase progress from agent states (sidebar / agent cards —
+ // doesn't touch transcript pane, safe to run regardless of replay outcome)
reconstructPhaseProgress(detail.agents || [], sessionKey);
- // Reconstruct timeline from audit log
+ // Reconstruct timeline from audit log (right-side timeline panel —
+ // doesn't touch transcript pane either)
reconstructTimeline(tlData.events || []);
// Populate session explorer
renderSessionExplorer(detail, tlData.events || []);
- // Show user's original query as first bubble (if persisted)
+ // Show user's original query as first bubble (always — not in transcript replay)
const userQuery = detail.session?.metadata?.query;
if (userQuery) {
addBubble('user', esc(userQuery));
}
- // Show session metadata as system bubble
- addBubble('system', `Loaded session ${esc(sessionKey)} — ${detail.session?.status || 'unknown'}`);
+ // v6.8.0: attempt full-fidelity transcript replay. If unavailable
+ // (pre-v6.8.0 sessions or flag-disabled at runtime), shows a fallback
+ // banner. Existing reconstruct calls above keep agent cards + timeline
+ // populated regardless.
+ const replayed = await replayTranscript(sessionKey);
+ if (!replayed) {
+ addBubble('system', `Loaded session ${esc(sessionKey)} — ${detail.session?.status || 'unknown'} (transcript replay unavailable — reconstructed view)`);
+ } else {
+ addBubble('system', `Loaded session ${esc(sessionKey)} — ${detail.session?.status || 'unknown'} (full transcript replayed)`);
+ }
// Refresh reports modal data
fetchReports();
@@ -1047,6 +1068,63 @@
}
};
+ // v6.8.0: Replay persisted SSE transcript through the live event dispatcher.
+ // Returns true if replay populated the transcript, false if no transcript
+ // available (pre-v6.8.0 session or fetch failure — caller falls back to
+ // existing reconstructed view).
+ //
+ // Performance: batches consecutive `delta` events into single appendText
+ // calls. Naive replay of 2000 deltas would do 2000 markdown re-renders;
+ // batching reduces this to ~30-50 (one per assistant turn).
+ async function replayTranscript(sessionKey) {
+ try {
+ const res = await fetch(
+ `${SERVER}/api/db/sessions/${encodeURIComponent(sessionKey)}/transcript`,
+ { credentials: 'include' }
+ );
+ if (!res.ok) return false;
+ const data = await res.json();
+ if (data.status !== 'available' || !Array.isArray(data.events) || data.events.length === 0) {
+ return false;
+ }
+
+ // Defensive sort by sequence_number (server already returns ASC)
+ const events = data.events.slice().sort(
+ (a, b) => Number(a.sequence_number) - Number(b.sequence_number)
+ );
+
+ replayMode = true;
+ try {
+ // Delta batching: accumulate consecutive delta events into one buffer,
+ // flushed when a non-delta event interrupts (or at end).
+ let deltaBuffer = '';
+ const flushDeltas = () => {
+ if (deltaBuffer.length > 0) {
+ handleStreamEvent({ type: 'delta', text: deltaBuffer });
+ deltaBuffer = '';
+ }
+ };
+ for (const e of events) {
+ const ed = e.event_data;
+ if (!ed) continue;
+ if (ed.type === 'delta' || ed.type === 'content_delta') {
+ deltaBuffer += (ed.text || '');
+ continue;
+ }
+ flushDeltas();
+ handleStreamEvent(ed);
+ }
+ flushDeltas();
+ } finally {
+ replayMode = false;
+ }
+ return true;
+ } catch (err) {
+ console.warn('[Replay] Transcript fetch failed:', err.message);
+ return false;
+ }
+ }
+
function reconstructPhaseProgress(agentStates, sessionKey) {
expandedAgents.clear();
agentProgress.clear();