Files
fusion/packages/dashboard/src/ai-session-store.ts
gsxdsm 375dd9054e fix(dashboard): invalidate draft summarizedFor on model switch
updateDraft preserved summarizedFor whenever the plan text was
unchanged, but the modal re-syncs the draft on model selection
changes too. Result: blur under model A, switch to model B without
editing text, click Start, and the running session kept model A's
title. The skip condition in startExistingSession only checks
text equality, not model identity.

Now updateDraft preserves summarizedFor only when both the plan
text AND the persisted modelProvider/modelId are unchanged, so a
model switch implicitly invalidates the marker and the start path
re-summarizes under whatever model the user just picked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:26:28 -07:00

867 lines
29 KiB
TypeScript

/**
* AI Session Store
*
* Persists long-running AI session state (planning, subtask breakdown,
* mission interview) to SQLite so users can dismiss modals and return
* later — even from a different browser.
*
* The in-memory session Maps in planning.ts / subtask-breakdown.ts /
* mission-interview.ts remain the source of truth for live agent state.
* This store is the persistence shadow, updated at each state transition.
*/
import { EventEmitter } from "node:events";
import type { Database } from "@fusion/core";
import { createSessionDiagnostics } from "./ai-session-diagnostics.js";
// ── Types ───────────────────────────────────────────────────────────────
export type AiSessionType = "planning" | "subtask" | "mission_interview" | "milestone_interview" | "slice_interview";
export type AiSessionStatus = "generating" | "awaiting_input" | "complete" | "error" | "draft";
export interface AiSessionRow {
id: string;
type: AiSessionType;
status: AiSessionStatus;
title: string;
inputPayload: string; // JSON string
conversationHistory: string; // JSON string: [{question, response}]
currentQuestion: string | null; // JSON string or null
result: string | null; // JSON string or null
thinkingOutput: string;
error: string | null;
projectId: string | null;
createdAt: string;
updatedAt: string;
lockedByTab: string | null;
lockedAt: string | null;
/** 1 if archived (hidden from planning sidebar), 0 otherwise. */
archived?: number;
}
/** Summary returned by listActive (omits large fields) */
export interface AiSessionSummary {
id: string;
type: AiSessionType;
status: AiSessionStatus;
title: string;
/**
* For draft planning sessions only: a short, derived preview of the
* persisted initialPlan so the sidebar can distinguish multiple drafts
* before the user has started any of them. Computed at read time from
* inputPayload — never persisted as the title — so unfinished keystrokes
* don't end up baked into the row's permanent title.
*/
preview?: string;
projectId: string | null;
lockedByTab: string | null;
updatedAt: string;
archived?: boolean;
}
/** Max characters of initialPlan surfaced as a sidebar preview for drafts. */
const DRAFT_PREVIEW_MAX_CHARS = 80;
export interface AiSessionStoreEvents {
"ai_session:updated": [AiSessionSummary];
"ai_session:deleted": [string]; // session id
}
// ── Constants ───────────────────────────────────────────────────────────
/** Max stored thinking output (50 KB). Older content trimmed from front. */
const MAX_THINKING_BYTES = 50 * 1024;
/** Debounce interval for thinking-only writes (ms). */
const THINKING_DEBOUNCE_MS = 2000;
/** Default max age before stale AI sessions are eligible for cleanup (7 days). */
export const SESSION_CLEANUP_DEFAULT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
/** Default scheduled interval for stale session cleanup runs (6 hours). */
export const SESSION_CLEANUP_INTERVAL_MS = 6 * 60 * 60 * 1000;
export interface AiSessionCleanupSummary {
terminalDeleted: number;
orphanedDeleted: number;
totalDeleted: number;
}
const diagnostics = createSessionDiagnostics("ai-session-store");
// ── Store ───────────────────────────────────────────────────────────────
export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
/** Pending debounce timers for thinking-only writes, keyed by session id. */
private thinkingTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Interval used for periodic stale-session cleanup. */
private cleanupTimer: ReturnType<typeof setInterval> | undefined;
constructor(private db: Database) {
super();
}
// ── CRUD ────────────────────────────────────────────────────────────
/**
* Insert or update an AI session row.
* Emits `ai_session:updated` after writing.
*/
upsert(session: AiSessionRow): void {
const now = new Date().toISOString();
const thinking = trimThinking(session.thinkingOutput);
this.db
.prepare(
`INSERT INTO ai_sessions (id, type, status, title, inputPayload, conversationHistory, currentQuestion, result, thinkingOutput, error, projectId, createdAt, updatedAt, lockedByTab, lockedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL)
ON CONFLICT(id) DO UPDATE SET
status = excluded.status,
title = excluded.title,
conversationHistory = excluded.conversationHistory,
currentQuestion = excluded.currentQuestion,
result = excluded.result,
thinkingOutput = excluded.thinkingOutput,
error = excluded.error,
updatedAt = excluded.updatedAt`,
)
.run(
session.id,
session.type,
session.status,
session.title,
session.inputPayload,
session.conversationHistory,
session.currentQuestion ?? null,
session.result ?? null,
thinking,
session.error ?? null,
session.projectId ?? null,
session.createdAt || now,
now,
);
// Cancel any pending thinking debounce for this session
this.clearThinkingTimer(session.id);
const row = this.get(session.id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
}
/**
* Update only the thinkingOutput field, debounced to reduce write frequency.
* Flushes immediately if `flush` is true (e.g. on status transition).
*/
updateThinking(sessionId: string, thinkingOutput: string, flush = false): void {
if (flush) {
this.clearThinkingTimer(sessionId);
this.writeThinking(sessionId, thinkingOutput);
return;
}
// Debounce: reset timer
this.clearThinkingTimer(sessionId);
const timer = setTimeout(() => {
this.thinkingTimers.delete(sessionId);
this.writeThinking(sessionId, thinkingOutput);
}, THINKING_DEBOUNCE_MS);
this.thinkingTimers.set(sessionId, timer);
}
/**
* Fetch a single session by ID. Returns null if not found.
*/
get(id: string): AiSessionRow | null {
const row = this.db
.prepare("SELECT * FROM ai_sessions WHERE id = ?")
.get(id) as unknown as AiSessionRow | undefined;
return row ?? null;
}
/**
* Atomically update only status/error for an existing session.
* Returns false when the session does not exist.
*/
updateStatus(id: string, status: AiSessionStatus, error?: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET status = ?, error = ?, updatedAt = ?
WHERE id = ?`,
)
.run(status, error ?? null, now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (!changed) {
return false;
}
const row = this.get(id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return true;
}
updateTitle(id: string, title: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET title = ?, updatedAt = ?
WHERE id = ?`,
)
.run(title, now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (!changed) {
return false;
}
const row = this.get(id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return true;
}
/**
* Atomically replace a draft session's title AND record the `initialPlan`
* text the title was summarized from. Lets the start path skip a redundant
* summarize when the persisted `summarizedFor` still matches the user's
* final text. Existing inputPayload fields (initialPlan, model override)
* are preserved by merge — this method only touches `summarizedFor`.
*/
markDraftSummarized(id: string, title: string, summarizedFor: string): boolean {
const existing = this.get(id);
if (!existing || existing.type !== "planning") return false;
let payload: Record<string, unknown> = {};
if (existing.inputPayload) {
try {
const parsed = JSON.parse(existing.inputPayload);
if (parsed && typeof parsed === "object") payload = parsed as Record<string, unknown>;
} catch {
// Fall through with empty payload — better to lose stale fields than
// to refuse the update and leave the title out of sync with reality.
}
}
payload.summarizedFor = summarizedFor;
const inputPayload = JSON.stringify(payload);
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET title = ?, inputPayload = ?, updatedAt = ?
WHERE id = ? AND type = 'planning'`,
)
.run(title, inputPayload, now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (!changed) return false;
const row = this.get(id);
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
return true;
}
/**
* Update persisted draft metadata for a planning session.
* Persists the in-progress initialPlan so it survives reload; the sidebar
* title is intentionally left alone (set once at creation, replaced when
* the user actually starts the session) to avoid leaking raw keystrokes
* into the sidebar and to keep the entry stable while editing.
*
* Also persists an optional model override paired together (provider+id);
* passing one without the other clears the persisted override so we never
* end up with a half-configured selection that the start path would
* silently reject.
*/
updateDraft(
id: string,
draft: { initialPlan: string; modelProvider?: string; modelId?: string },
): boolean {
const now = new Date().toISOString();
const trimmedPlan = draft.initialPlan.trim();
const hasModelOverride = Boolean(draft.modelProvider && draft.modelId);
// Preserve the prior `summarizedFor` field so summarize results aren't
// wiped on every draft sync, but only when both the plan text AND the
// model identity are unchanged. A model switch invalidates the prior
// summary even with identical text — otherwise startExistingSession
// would skip re-summarize and run the session with a title produced
// under a model the user just abandoned.
const existing = this.get(id);
let preservedSummarizedFor: string | undefined;
if (existing?.inputPayload) {
try {
const prev = JSON.parse(existing.inputPayload) as {
summarizedFor?: unknown;
modelProvider?: unknown;
modelId?: unknown;
};
const prevProvider = typeof prev.modelProvider === "string" ? prev.modelProvider : undefined;
const prevModelId = typeof prev.modelId === "string" ? prev.modelId : undefined;
const newProvider = hasModelOverride ? draft.modelProvider : undefined;
const newModelId = hasModelOverride ? draft.modelId : undefined;
const modelUnchanged = prevProvider === newProvider && prevModelId === newModelId;
if (
typeof prev.summarizedFor === "string"
&& prev.summarizedFor === trimmedPlan
&& modelUnchanged
) {
preservedSummarizedFor = prev.summarizedFor;
}
} catch {
// Ignore malformed prior payloads — treat as no summary on file.
}
}
const inputPayload = JSON.stringify({
initialPlan: trimmedPlan,
...(hasModelOverride ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}),
...(preservedSummarizedFor ? { summarizedFor: preservedSummarizedFor } : {}),
});
const result = this.db
.prepare(
`UPDATE ai_sessions
SET inputPayload = ?, updatedAt = ?
WHERE id = ? AND type = 'planning'`,
)
.run(inputPayload, now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (!changed) {
return false;
}
const row = this.get(id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return true;
}
/**
* Lightweight heartbeat for active sessions.
* Updates only `updatedAt` and intentionally does NOT emit
* `ai_session:updated` to avoid high-frequency SSE broadcasts.
*/
ping(id: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare("UPDATE ai_sessions SET updatedAt = ? WHERE id = ?")
.run(now, id) as { changes?: number };
return Number(result.changes ?? 0) > 0;
}
/**
* List active/retryable sessions (generating, awaiting_input, or error).
* Optionally filtered by projectId.
*/
listActive(projectId?: string): AiSessionSummary[] {
if (projectId) {
return this.db
.prepare(
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
AND projectId = ?
ORDER BY updatedAt DESC`,
)
.all(projectId) as unknown as AiSessionSummary[];
}
return this.db
.prepare(
`SELECT id, type, status, title, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input', 'error')
AND COALESCE(archived, 0) = 0
ORDER BY updatedAt DESC`,
)
.all() as unknown as AiSessionSummary[];
}
/**
* List sessions regardless of status (including `complete`).
* Used by the planning sidebar so previously completed sessions remain
* selectable on refresh — `listActive` filters them out, which would
* otherwise hide a session that finished while the modal was closed.
* By default archived sessions are excluded; pass `includeArchived` to
* surface them too. Completed sessions are pruned by `cleanupOld` after
* the configured TTL, so this list does not grow unbounded.
*/
listAll(projectId?: string, options?: { includeArchived?: boolean }): AiSessionSummary[] {
// Pull `inputPayload` alongside the summary columns so we can derive the
// sidebar preview for draft rows. Non-draft rows ignore the payload —
// toSidebarSummary only inspects it when status === "draft".
const archivedClause = options?.includeArchived ? "" : " WHERE COALESCE(archived, 0) = 0";
if (projectId) {
const where = options?.includeArchived
? "WHERE projectId = ?"
: "WHERE projectId = ? AND COALESCE(archived, 0) = 0";
const rows = this.db
.prepare(
`SELECT id, type, status, title, inputPayload, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
${where}
ORDER BY updatedAt DESC`,
)
.all(projectId) as Array<Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "type" | "status" | "title" | "inputPayload" | "updatedAt">>;
return rows.map(toSidebarSummary);
}
const rows = this.db
.prepare(
`SELECT id, type, status, title, inputPayload, projectId, lockedByTab, updatedAt, archived FROM ai_sessions
${archivedClause}
ORDER BY updatedAt DESC`,
)
.all() as Array<Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "type" | "status" | "title" | "inputPayload" | "updatedAt">>;
return rows.map(toSidebarSummary);
}
/**
* Mark a session as archived (hidden from planning sidebar). Only
* terminal sessions (`complete` or `error`) are archivable — archiving
* an in-flight session would orphan the live agent. Returns true when
* the row was updated. Emits `ai_session:updated` so other tabs sync.
*/
archive(id: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET archived = 1, updatedAt = ?
WHERE id = ? AND status IN ('complete', 'error')`,
)
.run(now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (changed) {
const row = this.get(id);
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return changed;
}
/** Restore an archived session so it reappears in the sidebar. */
unarchive(id: string): boolean {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET archived = 0, updatedAt = ?
WHERE id = ?`,
)
.run(now, id) as { changes?: number };
const changed = Number(result.changes ?? 0) > 0;
if (changed) {
const row = this.get(id);
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return changed;
}
/**
* List recoverable sessions for in-memory rehydration.
* Returns full rows for sessions still in progress.
*/
listRecoverable(projectId?: string): AiSessionRow[] {
if (projectId) {
return this.db
.prepare(
`SELECT * FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input') AND projectId = ?
ORDER BY updatedAt DESC`,
)
.all(projectId) as unknown as AiSessionRow[];
}
return this.db
.prepare(
`SELECT * FROM ai_sessions
WHERE status IN ('generating', 'awaiting_input')
ORDER BY updatedAt DESC`,
)
.all() as unknown as AiSessionRow[];
}
acquireLock(sessionId: string, tabId: string): { acquired: boolean; currentHolder: string | null } {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = ?, lockedAt = ?
WHERE id = ? AND (lockedByTab IS NULL OR lockedByTab = ?)`,
)
.run(tabId, now, sessionId, tabId) as { changes?: number };
const acquired = Number(result.changes ?? 0) > 0;
if (acquired) {
const row = this.get(sessionId);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return { acquired: true, currentHolder: null };
}
const holder = this.db
.prepare("SELECT lockedByTab FROM ai_sessions WHERE id = ?")
.get(sessionId) as { lockedByTab: string | null } | undefined;
return {
acquired: false,
currentHolder: holder?.lockedByTab ?? null,
};
}
releaseLock(sessionId: string, tabId: string): boolean {
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = NULL, lockedAt = NULL
WHERE id = ? AND lockedByTab = ?`,
)
.run(sessionId, tabId) as { changes?: number };
const released = Number(result.changes ?? 0) > 0;
if (!released) {
return false;
}
const row = this.get(sessionId);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
return true;
}
forceAcquireLock(sessionId: string, tabId: string): void {
const now = new Date().toISOString();
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = ?, lockedAt = ?
WHERE id = ?`,
)
.run(tabId, now, sessionId) as { changes?: number };
if (Number(result.changes ?? 0) === 0) {
return;
}
const row = this.get(sessionId);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
}
getLockHolder(sessionId: string): { tabId: string | null; lockedAt: string | null } {
const row = this.db
.prepare("SELECT lockedByTab, lockedAt FROM ai_sessions WHERE id = ?")
.get(sessionId) as { lockedByTab: string | null; lockedAt: string | null } | undefined;
return {
tabId: row?.lockedByTab ?? null,
lockedAt: row?.lockedAt ?? null,
};
}
releaseStaleLocks(maxAgeMs = 30 * 60 * 1000): number {
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
const staleRows = this.db
.prepare(
`SELECT id FROM ai_sessions
WHERE lockedByTab IS NOT NULL
AND lockedAt < ?`,
)
.all(cutoff) as Array<{ id: string }>;
if (staleRows.length === 0) {
return 0;
}
const result = this.db
.prepare(
`UPDATE ai_sessions
SET lockedByTab = NULL, lockedAt = NULL
WHERE lockedByTab IS NOT NULL
AND lockedAt < ?`,
)
.run(cutoff) as { changes?: number };
for (const rowInfo of staleRows) {
const row = this.get(rowInfo.id);
if (row) {
this.emit("ai_session:updated", toSummary(row, row.updatedAt));
}
}
return Number(result.changes ?? 0);
}
/**
* Delete a session by ID. Emits `ai_session:deleted`.
*/
delete(id: string): void {
this.clearThinkingTimer(id);
this.db.prepare("DELETE FROM ai_sessions WHERE id = ?").run(id);
this.emit("ai_session:deleted", id);
}
/**
* Recover sessions after server restart.
* - `generating` sessions with a currentQuestion -> `awaiting_input`
* - `generating` sessions without -> `error`
*/
recoverStaleSessions(): number {
const now = new Date().toISOString();
let recovered = 0;
// Sessions that were generating and had a pending question — recoverable
const withQuestion = this.db
.prepare(
`UPDATE ai_sessions SET status = 'awaiting_input', updatedAt = ?
WHERE status = 'generating' AND currentQuestion IS NOT NULL`,
)
.run(now) as { changes?: number };
recovered += Number(withQuestion.changes ?? 0);
// Sessions that were generating with no question — unrecoverable
const withoutQuestion = this.db
.prepare(
`UPDATE ai_sessions SET status = 'error', error = 'Session interrupted — please restart', updatedAt = ?
WHERE status = 'generating' AND currentQuestion IS NULL`,
)
.run(now) as { changes?: number };
recovered += Number(withoutQuestion.changes ?? 0);
if (recovered > 0) {
diagnostics.info("Recovered stale sessions after restart", {
recovered,
operation: "recover-stale-sessions",
});
}
return recovered;
}
/**
* Clean up stale terminal sessions (`complete`, `error`) older than the given age (ms).
* Returns the number of deleted sessions.
*/
cleanupOld(maxAgeMs: number): number {
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
const stale = this.db
.prepare(
`SELECT id FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('complete', 'error')`,
)
.all(cutoff) as Array<{ id: string }>;
if (stale.length === 0) {
return 0;
}
this.db
.prepare(
`DELETE FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('complete', 'error')`,
)
.run(cutoff);
this.emitDeletedSessions(stale);
return stale.length;
}
/**
* Cleans up stale terminal and orphaned active sessions older than `maxAgeMs`.
*
* - Terminal sessions (`complete`, `error`) are deleted via `cleanupOld()`.
* - Orphaned active sessions (`generating`, `awaiting_input`) are deleted directly.
*/
cleanupStaleSessions(maxAgeMs = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS): AiSessionCleanupSummary {
const terminalDeleted = this.cleanupOld(maxAgeMs);
const cutoff = new Date(Date.now() - maxAgeMs).toISOString();
const orphaned = this.db
.prepare(
`SELECT id FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('generating', 'awaiting_input')`,
)
.all(cutoff) as Array<{ id: string }>;
let orphanedDeleted = 0;
if (orphaned.length > 0) {
const result = this.db
.prepare(
`DELETE FROM ai_sessions
WHERE updatedAt < ?
AND status IN ('generating', 'awaiting_input')`,
)
.run(cutoff) as { changes?: number };
orphanedDeleted = Number(result.changes ?? 0);
this.emitDeletedSessions(orphaned);
}
const totalDeleted = terminalDeleted + orphanedDeleted;
diagnostics.info("Cleanup removed stale sessions", {
terminalDeleted,
orphanedDeleted,
totalDeleted,
maxAgeMs,
operation: "cleanup-stale-sessions",
});
return {
terminalDeleted,
orphanedDeleted,
totalDeleted,
};
}
/**
* Start periodic stale-session cleanup using the provided schedule and TTL.
*/
startScheduledCleanup(cleanupIntervalMs: number, ttlMs: number): void {
this.stopScheduledCleanup();
const runCleanup = () => {
try {
this.cleanupStaleSessions(ttlMs);
} catch (error) {
diagnostics.errorFromException("Scheduled cleanup failed", error, {
ttlMs,
operation: "scheduled-cleanup",
});
}
};
this.cleanupTimer = setInterval(runCleanup, cleanupIntervalMs);
this.cleanupTimer.unref?.();
}
/**
* Stop periodic stale-session cleanup if currently running.
*/
stopScheduledCleanup(): void {
if (!this.cleanupTimer) {
return;
}
clearInterval(this.cleanupTimer);
this.cleanupTimer = undefined;
}
// ── Internal ────────────────────────────────────────────────────────
private emitDeletedSessions(rows: Array<{ id: string }>): void {
for (const { id } of rows) {
this.clearThinkingTimer(id);
this.emit("ai_session:deleted", id);
}
}
private writeThinking(sessionId: string, thinkingOutput: string): void {
const now = new Date().toISOString();
this.db
.prepare("UPDATE ai_sessions SET thinkingOutput = ?, updatedAt = ? WHERE id = ?")
.run(trimThinking(thinkingOutput), now, sessionId);
}
private clearThinkingTimer(id: string): void {
const timer = this.thinkingTimers.get(id);
if (timer) {
clearTimeout(timer);
this.thinkingTimers.delete(id);
}
}
}
// ── Helpers ─────────────────────────────────────────────────────────────
function trimThinking(output: string): string {
if (output.length <= MAX_THINKING_BYTES) return output;
return output.slice(output.length - MAX_THINKING_BYTES);
}
function toSummary(session: AiSessionRow, updatedAt: string): AiSessionSummary {
return {
id: session.id,
type: session.type,
status: session.status,
title: session.title,
preview: extractDraftPreview(session),
projectId: session.projectId,
lockedByTab: session.lockedByTab ?? null,
updatedAt,
archived: Number(session.archived ?? 0) === 1,
};
}
/**
* Lighter-weight summary builder for `listAll` rows that don't carry every
* column of `AiSessionRow`. Keeps the same preview-derivation behavior as
* `toSummary` (drafts only) without forcing the bulk-list query to SELECT
* conversationHistory / thinkingOutput / etc.
*/
function toSidebarSummary(
row: Partial<AiSessionRow> & Pick<AiSessionRow, "id" | "type" | "status" | "title" | "inputPayload" | "updatedAt">,
): AiSessionSummary {
const previewSource: AiSessionRow = {
id: row.id,
type: row.type,
status: row.status,
title: row.title,
inputPayload: row.inputPayload,
conversationHistory: "",
currentQuestion: null,
result: null,
thinkingOutput: "",
error: null,
projectId: row.projectId ?? null,
createdAt: "",
updatedAt: row.updatedAt,
lockedByTab: row.lockedByTab ?? null,
lockedAt: row.lockedAt ?? null,
archived: row.archived,
};
return {
id: row.id,
type: row.type,
status: row.status,
title: row.title,
preview: extractDraftPreview(previewSource),
projectId: row.projectId ?? null,
lockedByTab: row.lockedByTab ?? null,
updatedAt: row.updatedAt,
archived: Number(row.archived ?? 0) === 1,
};
}
function extractDraftPreview(session: AiSessionRow): string | undefined {
if (session.type !== "planning" || session.status !== "draft") return undefined;
if (!session.inputPayload) return undefined;
try {
const payload = JSON.parse(session.inputPayload) as { initialPlan?: unknown };
const plan = typeof payload.initialPlan === "string" ? payload.initialPlan.trim() : "";
if (!plan) return undefined;
const collapsed = plan.replace(/\s+/g, " ");
return collapsed.length > DRAFT_PREVIEW_MAX_CHARS
? `${collapsed.slice(0, DRAFT_PREVIEW_MAX_CHARS - 1).trimEnd()}`
: collapsed;
} catch {
return undefined;
}
}