| import { join } from 'node:path'; |
| import { createHash, randomUUID } from 'node:crypto'; |
| import { |
| createSqliteSessionMetadataStore, |
| type SessionConfigurationMetadataUpdate, |
| type SessionCatalogRevisionState, |
| type SessionMetadataRecord, |
| type SessionRemovalProbe, |
| SessionMetadataVersionConflictError, |
| type SqliteSessionMetadataStore, |
| type StableSessionCreateProbe, |
| type UnresolvedProjectSession, |
| type VersionedSessionIdentity, |
| } from './sqlite-session-metadata-store.js'; |
| import { isDiscardableConversationCopy } from './session-conversation-copy.js'; |
| import { importLegacySessionsOnce } from './legacy-session-import.js'; |
| import { |
| acquireOperationalStateDatabase, |
| OPERATIONAL_STATE_DATABASE_NAME, |
| } from './operational-state-store.js'; |
| import { |
| DEFAULT_SESSION_NAME, |
| decodeStoredMessageForRecovery, |
| deriveTurnRecords, |
| isCollaborationMode, |
| isOrchestrationMode, |
| isPermissionMode, |
| isSessionBlockedReason, |
| isSessionConversationCopy, |
| isSubagentSessionParent, |
| isSubagentSessionRuntime, |
| isSubagentSessionSpawn, |
| isSubagentWorkspaceBinding, |
| isSessionStatus, |
| normalizeUserSessionName, |
| subagentSessionRuntimeSummary, |
| WORKSPACE_AUTHORITY_SESSION_ID, |
| } from '@maka/core'; |
| import type { |
| AgentGraphOperatorProvisionRequest, |
| AgentGraphOperatorProvisionResult, |
| CreateSandboxBoundaryRequest, |
| CreateSessionInput, |
| ExecutionBoundary, |
| SandboxBoundaryRequest, |
| SandboxBoundarySettlement, |
| SessionHeader, |
| SessionConversationCopy, |
| SessionListFilter, |
| SessionSummary, |
| StoredMessage, |
| SettleSandboxBoundaryRequest, |
| TurnRecord, |
| UserMessage, |
| } from '@maka/core'; |
| |
| const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; |
| |
| export function isSafeSessionId(sessionId: string): boolean { |
| return SESSION_ID_PATTERN.test(sessionId); |
| } |
| |
| export function assertSafeSessionId(sessionId: string): void { |
| if (!isSafeSessionId(sessionId)) throw new Error(`Invalid Session id: ${sessionId}`); |
| } |
| export class SessionNotFoundError extends Error { |
| readonly name = 'SessionNotFoundError'; |
| readonly code = 'session_not_found'; |
| |
| constructor(readonly sessionId: string) { |
| super(`Session metadata not found: ${sessionId}`); |
| } |
| } |
| |
| export function isSessionNotFoundError(error: unknown): error is SessionNotFoundError { |
| return error instanceof SessionNotFoundError; |
| } |
| |
| export class SessionReadMarkerMessageNotFoundError extends Error { |
| readonly name = 'SessionReadMarkerMessageNotFoundError'; |
| readonly code = 'session_read_marker_message_not_found'; |
| |
| constructor( |
| readonly sessionId: string, |
| readonly messageId: string, |
| ) { |
| super(`Session read marker message does not exist: ${messageId}`); |
| } |
| } |
| |
| export interface SessionHeaderSnapshot { |
| readonly header: SessionHeader; |
| readonly revision: number; |
| readonly committedAt: number; |
| } |
| |
| export type ProbeSessionRemovalResult = |
| | { readonly kind: 'present'; readonly record: SessionHeaderSnapshot } |
| | { readonly kind: 'removed' } |
| | { readonly kind: 'absent' }; |
| |
| export interface SessionCatalogRecord extends SessionHeaderSnapshot { |
| readonly summary: SessionSummary; |
| } |
| |
| export interface SessionCatalogPageCursor { |
| readonly activityAt: number; |
| readonly sessionId: string; |
| } |
| |
| export type SessionCatalogPageResult = |
| | { |
| readonly kind: 'page'; |
| readonly revision: `sha256:${string}`; |
| readonly records: readonly SessionCatalogRecord[]; |
| readonly hasMore: boolean; |
| } |
| | { |
| readonly kind: 'revision_changed'; |
| readonly expectedRevision: `sha256:${string}`; |
| readonly actualRevision: `sha256:${string}`; |
| }; |
| |
| export interface CreateStableSessionRequest { |
| readonly sessionId: string; |
| readonly requestFingerprint: string; |
| readonly input: StableSessionCreateInput; |
| } |
| |
| export type StableSessionCreateInput = CreateSessionInput & { |
| readonly conversationCopy?: SessionConversationCopy; |
| }; |
| |
| export type CreateStableSessionResult = |
| | { readonly kind: 'created'; readonly record: SessionHeaderSnapshot } |
| | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } |
| | { |
| readonly kind: 'conflict'; |
| readonly reason: 'identity_mismatch' | 'removed'; |
| }; |
| |
| export type ProbeStableSessionCreateResult = |
| | { readonly kind: 'absent' } |
| | { readonly kind: 'existing'; readonly record: SessionHeaderSnapshot } |
| | { |
| readonly kind: 'conflict'; |
| readonly reason: 'identity_mismatch' | 'removed'; |
| }; |
| |
| export type UpdateSessionConfigurationRequest = SessionConfigurationMetadataUpdate; |
| |
| export interface SessionStore { |
| create(input: CreateSessionInput, initialBoundary?: ExecutionBoundary): Promise<SessionHeader>; |
| list(filter?: SessionListFilter): Promise<SessionSummary[]>; |
| /** Enumerate durable metadata without reading transcript bodies. */ |
| listHeaders(): Promise<SessionHeader[]>; |
| /** Sessions whose project membership was never decided, newest activity last. */ |
| listSessionsWithUnresolvedProject(): Promise<UnresolvedProjectSession[]>; |
| listForRecovery(): Promise<SessionHeader[]>; |
| /** Read only the durable header without triggering connection-lock self-healing. */ |
| readHeaderSnapshot(sessionId: string): Promise<SessionHeader>; |
| /** Read durable messages without triggering connection-lock self-healing. */ |
| readMessagesSnapshot(sessionId: string): Promise<StoredMessage[]>; |
| /** Read durable messages for startup recovery. */ |
| readMessagesForRecovery(sessionId: string): Promise<StoredMessage[]>; |
| /** Derive durable turns without triggering connection-lock self-healing. */ |
| listTurnsSnapshot(sessionId: string): Promise<TurnRecord[]>; |
| readHeader(sessionId: string): Promise<SessionHeader>; |
| readMessages(sessionId: string): Promise<StoredMessage[]>; |
| listTurns(sessionId: string): Promise<TurnRecord[]>; |
| appendMessage(sessionId: string, message: StoredMessage): Promise<void>; |
| appendMessages(sessionId: string, messages: StoredMessage[]): Promise<void>; |
| updateHeader(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionHeader>; |
| markSessionReadThrough(sessionId: string, readThroughTs: number): Promise<SessionHeader>; |
| archive(sessionId: string): Promise<void>; |
| unarchive(sessionId: string): Promise<void>; |
| setFlagged(sessionId: string, isFlagged: boolean): Promise<void>; |
| rename(sessionId: string, name: string): Promise<void>; |
| setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise<SessionHeader | null>; |
| remove(sessionId: string): Promise<void>; |
| close?(): Promise<void>; |
| } |
| |
| export interface SessionAuthorityStore extends SessionStore { |
| /** Atomically create a Session from already-converted Maka raw messages. */ |
| createImportedSession( |
| input: CreateSessionInput, |
| messages: readonly StoredMessage[], |
| ): Promise<SessionHeader>; |
| createSubagent( |
| input: CreateSessionInput, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader; created: boolean }>; |
| createAgentGraphOperator( |
| input: CreateSessionInput, |
| request: AgentGraphOperatorProvisionRequest, |
| expectedRevision: number, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult>; |
| readExecutionBoundary(sessionId: string): Promise<ExecutionBoundary>; |
| createSandboxBoundaryRequest( |
| input: CreateSandboxBoundaryRequest, |
| ): Promise<SandboxBoundaryRequest>; |
| readSandboxBoundaryRequest( |
| sessionId: string, |
| requestId: string, |
| ): Promise<SandboxBoundaryRequest | undefined>; |
| listPendingSandboxBoundaryRequests(sessionId: string): Promise<SandboxBoundaryRequest[]>; |
| /** Requests already closed against the user because the host restarted. */ |
| listSandboxBoundaryRestartClosures(sessionId: string): Promise<SandboxBoundaryRequest[]>; |
| settleSandboxBoundaryRequest( |
| input: SettleSandboxBoundaryRequest, |
| ): Promise<SandboxBoundarySettlement>; |
| setExecutionBoundaryKind( |
| sessionId: string, |
| kind: 'managed' | 'bypass', |
| projection?: { |
| permissionMode: SessionHeader['permissionMode']; |
| labels?: readonly string[]; |
| }, |
| ): Promise<ExecutionBoundary>; |
| probeStableSessionCreate( |
| sessionId: string, |
| requestFingerprint: string, |
| ): Promise<ProbeStableSessionCreateResult>; |
| createStableSession( |
| request: CreateStableSessionRequest, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<CreateStableSessionResult>; |
| discardStableConversationCopy(sessionId: string, requestFingerprint: string): Promise<boolean>; |
| /** |
| * Insert a session with its historical facts atomically (header + messages |
| * in one transaction, idempotent by session id). Used by the one-time |
| * legacy JSONL importer; not part of the normal session lifecycle. |
| */ |
| importSession( |
| header: SessionHeader, |
| messages: readonly StoredMessage[], |
| ): Promise<'imported' | 'existing'>; |
| /** |
| * Cheap existence probe used by the legacy importer to skip ids already in |
| * SQLite before reading their transcripts. |
| */ |
| hasSession(sessionId: string): Promise<boolean>; |
| listCatalogPage( |
| filter: SessionListFilter | undefined, |
| cursor: SessionCatalogPageCursor | undefined, |
| limit: number, |
| expectedRevision?: `sha256:${string}`, |
| ): Promise<SessionCatalogPageResult>; |
| readHeaderRecordSnapshot(sessionId: string): Promise<SessionHeaderSnapshot>; |
| readCatalogRecord(sessionId: string): Promise<SessionCatalogRecord>; |
| updateHeaderVersioned( |
| sessionId: string, |
| patch: Partial<SessionHeader>, |
| expectedRevision: number, |
| ): Promise<SessionHeaderSnapshot>; |
| updateSessionConfiguration( |
| sessionId: string, |
| input: UpdateSessionConfigurationRequest, |
| ): Promise<SessionHeaderSnapshot>; |
| markSessionReadThroughMessage( |
| sessionId: string, |
| messageId: string, |
| ): Promise<SessionHeaderSnapshot>; |
| probeSessionRemoval(sessionId: string): Promise<ProbeSessionRemovalResult>; |
| setSessionsLifecycleVersioned( |
| sessions: readonly VersionedSessionIdentity[], |
| state: 'active' | 'archived', |
| ): Promise<SessionHeaderSnapshot[]>; |
| removeSessionsVersioned(sessions: readonly VersionedSessionIdentity[]): Promise<string[]>; |
| reconcileOrphanedAgentGraphRetirements(): Promise<string[]>; |
| listPendingSessionRetirementCleanupIds(sessionId?: string): Promise<string[]>; |
| completeSessionRetirementCleanup(sessionId: string): Promise<void>; |
| } |
| |
| interface SessionAuthorityStoreTestDependencies { |
| readonly beforeTranscriptRemoval?: (sessionId: string) => Promise<void>; |
| } |
| |
| export function createSessionStore(workspaceRoot: string): SessionAuthorityStore { |
| return new SqliteSessionStore(workspaceRoot, {}); |
| } |
| |
| /** @internal Test-only dependency injection; not exported from the package root. */ |
| export function createSessionStoreWithTestDependencies( |
| workspaceRoot: string, |
| dependencies: SessionAuthorityStoreTestDependencies, |
| ): SessionAuthorityStore { |
| return new SqliteSessionStore(workspaceRoot, dependencies); |
| } |
| |
| class SqliteSessionStore implements SessionAuthorityStore { |
| private readonly metadata: SqliteSessionMetadataStore; |
| private readonly workspaceRoot: string; |
| private readyPromise: Promise<void> | null = null; |
| private closePromise: Promise<void> | null = null; |
| |
| constructor(workspaceRoot: string, _dependencies: SessionAuthorityStoreTestDependencies) { |
| this.workspaceRoot = workspaceRoot; |
| const databaseLease = acquireOperationalStateDatabase(workspaceRoot); |
| this.metadata = createSqliteSessionMetadataStore( |
| join(workspaceRoot, OPERATIONAL_STATE_DATABASE_NAME), |
| { databaseLease }, |
| ); |
| } |
| |
| /** |
| * One-time legacy JSONL session import, awaited by every public method so |
| * upgraded installs see their pre-cutover sessions from any entry point — |
| * desktop boot, CLI, headless, and `maka --resume <legacy-id>` all reach a |
| * read/write method before touching session data, and each awaits this |
| * latch (same shape as `importLegacyCatalogOnce` in project-catalog.ts). |
| * |
| * The import itself is best-effort: per-file errors are reported in the |
| * result and never thrown, and a whole-run failure (e.g. an unreadable |
| * sessions/ directory) is logged and dropped rather than taking the read |
| * path down with it. The outcome is surfaced to the log so a failure is |
| * observable instead of silently swallowed — the feature's purpose (data |
| * appears in the UI) can otherwise fail with zero signal. |
| */ |
| private ensureReady(): Promise<void> { |
| this.readyPromise ??= this.importLegacySessionsOnce(); |
| return this.readyPromise; |
| } |
| |
| private async importLegacySessionsOnce(): Promise<void> { |
| try { |
| const result = await importLegacySessionsOnce(this, this.workspaceRoot); |
| if (result.failed > 0) { |
| console.warn( |
| `[legacy-session-import] ${result.failed} of ${result.imported + result.skipped + result.failed} legacy session(s) failed to import; ` + |
| `failures: ${result.failures.map((failure) => `${failure.sessionId}: ${failure.error}`).join(' | ')}`, |
| ); |
| } else if (result.imported > 0) { |
| console.info(`[legacy-session-import] imported ${result.imported} legacy session(s)`); |
| } |
| } catch (error) { |
| console.error( |
| '[legacy-session-import] import run failed:', |
| error instanceof Error ? error.message : String(error), |
| ); |
| } |
| } |
| |
| async create( |
| input: CreateSessionInput, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<SessionHeader> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| if (input.subagentSpawn) { |
| throw new Error('Subagent spawn metadata requires createSubagent()'); |
| } |
| return ( |
| await this.metadata.create(buildSessionHeader(this.workspaceRoot, input), initialBoundary) |
| ).header; |
| } |
| |
| async createImportedSession( |
| input: CreateSessionInput, |
| messages: readonly StoredMessage[], |
| ): Promise<SessionHeader> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| if (input.subagentSpawn) { |
| throw new Error('Subagent spawn metadata requires createSubagent()'); |
| } |
| const canonicalMessages = messages.map((message) => |
| decodeStoredMessageForRecovery(JSON.parse(JSON.stringify(message)) as unknown), |
| ); |
| const header = buildSessionHeader(this.workspaceRoot, input); |
| const outcome = await this.metadata.importSession( |
| header, |
| canonicalMessages, |
| projectSessionCatalogMessages(canonicalMessages), |
| ); |
| if (outcome !== 'imported') { |
| throw new Error(`Generated Session id already exists: ${header.id}`); |
| } |
| return (await this.metadata.read(header.id)).header; |
| } |
| |
| async probeStableSessionCreate( |
| sessionId: string, |
| requestFingerprint: string, |
| ): Promise<ProbeStableSessionCreateResult> { |
| await this.ensureReady(); |
| return projectStableSessionCreateProbe( |
| await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint), |
| ); |
| } |
| |
| async createStableSession( |
| request: CreateStableSessionRequest, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<CreateStableSessionResult> { |
| await this.ensureReady(); |
| if ( |
| request.input.conversationCopy && |
| request.input.conversationCopy.requestFingerprint !== request.requestFingerprint |
| ) { |
| throw new Error('Conversation copy fingerprint does not match the stable create request'); |
| } |
| if (request.input.subagentSpawn) { |
| throw new Error('Subagent spawn metadata requires createSubagent()'); |
| } |
| const probe = await this.metadata.claimStableSessionCreate( |
| request.sessionId, |
| request.requestFingerprint, |
| ); |
| if (probe.kind === 'existing') { |
| return { kind: 'existing', record: projectHeaderSnapshot(probe.record) }; |
| } |
| if (probe.kind === 'conflict') return probe; |
| |
| const result = await this.metadata.createStableSession( |
| buildSessionHeader( |
| this.workspaceRoot, |
| request.input, |
| request.sessionId, |
| request.input.conversationCopy, |
| ), |
| request.requestFingerprint, |
| initialBoundary, |
| ); |
| return result.kind === 'created' || result.kind === 'existing' |
| ? { kind: result.kind, record: projectHeaderSnapshot(result.record) } |
| : result; |
| } |
| |
| async discardStableConversationCopy( |
| sessionId: string, |
| requestFingerprint: string, |
| ): Promise<boolean> { |
| await this.ensureReady(); |
| if (!(await this.metadata.hasStableSessionCreateClaim(sessionId, requestFingerprint))) { |
| throw new Error('Session is not owned by the matching stable create request'); |
| } |
| const probe = await this.metadata.probeStableSessionCreate(sessionId, requestFingerprint); |
| if (probe.kind === 'conflict') { |
| throw new Error('Stable Session identity belongs to a different request'); |
| } |
| if (probe.kind === 'existing') { |
| const copy = probe.record.header.conversationCopy; |
| if ( |
| copy?.requestFingerprint !== requestFingerprint || |
| !isDiscardableConversationCopy(probe.record.header) |
| ) { |
| throw new Error('Only a matching incomplete conversation copy can be discarded'); |
| } |
| } |
| return this.metadata.discardStableSessionCreate(sessionId, requestFingerprint); |
| } |
| |
| async importSession( |
| header: SessionHeader, |
| messages: readonly StoredMessage[], |
| ): Promise<'imported' | 'existing'> { |
| // Deliberately not awaited against ensureReady: the importer drives the |
| // migration, so gating its own write primitive on the same latch would |
| // self-deadlock. The store is already ready by construction here (the |
| // metadata store is created in the constructor and importSession only |
| // touches it). |
| return this.metadata.importSession(header, messages, projectSessionCatalogMessages(messages)); |
| } |
| |
| async hasSession(sessionId: string): Promise<boolean> { |
| // Same rationale as importSession: no ensureReady gate — the importer |
| // drives the migration and must not await its own latch. |
| return this.metadata.hasSession(sessionId); |
| } |
| |
| async createSubagent( |
| input: CreateSessionInput, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader; created: boolean }> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| const result = await this.metadata.createSubagent( |
| buildSessionHeader(this.workspaceRoot, input), |
| initialBoundary, |
| ); |
| return { header: result.record.header, created: result.created }; |
| } |
| |
| async createAgentGraphOperator( |
| input: CreateSessionInput, |
| request: AgentGraphOperatorProvisionRequest, |
| expectedRevision: number, |
| initialBoundary?: ExecutionBoundary, |
| ): Promise<{ header: SessionHeader } & AgentGraphOperatorProvisionResult> { |
| await this.ensureReady(); |
| assertNoConversationCopyMetadata(input); |
| const result = await this.metadata.createAgentGraphOperator( |
| buildSessionHeader(this.workspaceRoot, input), |
| request, |
| expectedRevision, |
| initialBoundary, |
| ); |
| return { |
| header: result.record.header, |
| provision: result.provision, |
| created: result.created, |
| }; |
| } |
| |
| async readExecutionBoundary(sessionId: string): Promise<ExecutionBoundary> { |
| await this.ensureReady(); |
| return this.metadata.readExecutionBoundary(sessionId); |
| } |
| |
| async createSandboxBoundaryRequest( |
| input: CreateSandboxBoundaryRequest, |
| ): Promise<SandboxBoundaryRequest> { |
| await this.ensureReady(); |
| return this.metadata.createSandboxBoundaryRequest(input); |
| } |
| |
| async readSandboxBoundaryRequest( |
| sessionId: string, |
| requestId: string, |
| ): Promise<SandboxBoundaryRequest | undefined> { |
| await this.ensureReady(); |
| return this.metadata.readSandboxBoundaryRequest(sessionId, requestId); |
| } |
| |
| async listPendingSandboxBoundaryRequests(sessionId: string): Promise<SandboxBoundaryRequest[]> { |
| await this.ensureReady(); |
| return this.metadata.listPendingSandboxBoundaryRequests(sessionId); |
| } |
| |
| async listSandboxBoundaryRestartClosures(sessionId: string): Promise<SandboxBoundaryRequest[]> { |
| await this.ensureReady(); |
| return this.metadata.listSandboxBoundaryRestartClosures(sessionId); |
| } |
| |
| async settleSandboxBoundaryRequest( |
| input: SettleSandboxBoundaryRequest, |
| ): Promise<SandboxBoundarySettlement> { |
| await this.ensureReady(); |
| return this.metadata.settleSandboxBoundaryRequest(input); |
| } |
| |
| async setExecutionBoundaryKind( |
| sessionId: string, |
| kind: 'managed' | 'bypass', |
| projection?: { |
| permissionMode: SessionHeader['permissionMode']; |
| labels?: readonly string[]; |
| }, |
| ): Promise<ExecutionBoundary> { |
| await this.ensureReady(); |
| return this.metadata.setExecutionBoundaryKind(sessionId, kind, projection); |
| } |
| |
| async list(filter?: SessionListFilter): Promise<SessionSummary[]> { |
| await this.ensureReady(); |
| const records = (await this.metadata.list(filter)).filter( |
| (record) => record.header.conversationCopy?.state !== 'preparing', |
| ); |
| const withPreviews: Array<{ |
| record: SessionMetadataRecord; |
| previewMessages: StoredMessage[]; |
| }> = []; |
| for (const record of records) { |
| const previewMessages = await this.metadata.readPreviewMessages(record.header.id); |
| withPreviews.push({ record, previewMessages }); |
| } |
| withPreviews.sort((a, b) => { |
| const aLastMessageAt = maxTimestamp( |
| a.record.header.lastMessageAt, |
| latestVisibleMessageAt(a.previewMessages), |
| ); |
| const bLastMessageAt = maxTimestamp( |
| b.record.header.lastMessageAt, |
| latestVisibleMessageAt(b.previewMessages), |
| ); |
| const tsDelta = (bLastMessageAt ?? 0) - (aLastMessageAt ?? 0); |
| return tsDelta !== 0 ? tsDelta : a.record.header.id.localeCompare(b.record.header.id); |
| }); |
| |
| const summaries: SessionSummary[] = []; |
| for (let index = 0; index < withPreviews.length; index += 1) { |
| const { record, previewMessages } = withPreviews[index]!; |
| const { header } = record; |
| let messages = previewMessages.slice(-10); |
| if (index < 3) { |
| messages = (await this.metadata.readMessages(header.id)).slice(-10); |
| } |
| summaries.push(toSummary(header, messages)); |
| } |
| return summaries; |
| } |
| |
| async listCatalogPage( |
| filter: SessionListFilter | undefined, |
| cursor: SessionCatalogPageCursor | undefined, |
| limit: number, |
| expectedRevision?: `sha256:${string}`, |
| ): Promise<SessionCatalogPageResult> { |
| await this.ensureCatalogProjectionReadable(); |
| const page = await this.metadata.listCatalogPage(filter ?? {}, cursor, limit); |
| const revision = projectCatalogRevision(page.revision); |
| if (expectedRevision !== undefined && expectedRevision !== revision) { |
| return { |
| kind: 'revision_changed', |
| expectedRevision, |
| actualRevision: revision, |
| }; |
| } |
| |
| return { |
| kind: 'page', |
| revision, |
| records: page.records.map((record) => ({ |
| ...projectHeaderSnapshot(record), |
| summary: toCatalogSummary(record.header, record.lastMessagePreview), |
| })), |
| hasMore: page.hasMore, |
| }; |
| } |
| |
| async listForRecovery(): Promise<SessionHeader[]> { |
| const headers = await this.listHeaders(); |
| for (const header of headers) { |
| await this.metadata.readMessagesForRecovery(header.id); |
| } |
| return headers; |
| } |
| |
| async listHeaders(): Promise<SessionHeader[]> { |
| await this.ensureReady(); |
| return (await this.metadata.list()) |
| .map((record) => record.header) |
| .sort((a, b) => a.id.localeCompare(b.id)); |
| } |
| |
| async listSessionsWithUnresolvedProject(): Promise<UnresolvedProjectSession[]> { |
| await this.ensureReady(); |
| return this.metadata.listSessionsWithUnresolvedProject(); |
| } |
| |
| async readHeaderSnapshot(sessionId: string): Promise<SessionHeader> { |
| return (await this.readHeaderRecordSnapshot(sessionId)).header; |
| } |
| |
| async readHeaderRecordSnapshot(sessionId: string): Promise<SessionHeaderSnapshot> { |
| await this.ensureReady(); |
| // `maka --resume <legacy-id>` reads the header before any list; the |
| // import runs in ensureReady, so the first post-upgrade resume of a |
| // pre-cutover session sees its imported rows. |
| return projectHeaderSnapshot(await this.metadata.read(sessionId)); |
| } |
| |
| async readCatalogRecord(sessionId: string): Promise<SessionCatalogRecord> { |
| await this.ensureCatalogProjectionReadable(); |
| const record = await this.metadata.readCatalogRecord(sessionId); |
| return { |
| ...projectHeaderSnapshot(record), |
| summary: toCatalogSummary(record.header, record.lastMessagePreview), |
| }; |
| } |
| |
| async readMessagesSnapshot(sessionId: string): Promise<StoredMessage[]> { |
| await this.ensureReady(); |
| return this.metadata.readMessages(sessionId); |
| } |
| |
| async readMessagesForRecovery(sessionId: string): Promise<StoredMessage[]> { |
| await this.ensureReady(); |
| return this.metadata.readMessagesForRecovery(sessionId); |
| } |
| |
| async listTurnsSnapshot(sessionId: string): Promise<TurnRecord[]> { |
| return deriveTurnRecords(await this.readMessagesSnapshot(sessionId)); |
| } |
| |
| async readHeader(sessionId: string): Promise<SessionHeader> { |
| return this.readHeaderSnapshot(sessionId); |
| } |
| |
| async readMessages(sessionId: string): Promise<StoredMessage[]> { |
| return this.readMessagesSnapshot(sessionId); |
| } |
| |
| async listTurns(sessionId: string): Promise<TurnRecord[]> { |
| return deriveTurnRecords(await this.readMessages(sessionId)); |
| } |
| |
| async appendMessage(sessionId: string, message: StoredMessage): Promise<void> { |
| await this.appendMessages(sessionId, [message]); |
| } |
| |
| async appendMessages(sessionId: string, messages: StoredMessage[]): Promise<void> { |
| if (messages.length === 0) return; |
| await this.ensureReady(); |
| await this.metadata.appendMessages( |
| sessionId, |
| messages, |
| projectSessionCatalogMessages(messages), |
| ); |
| } |
| |
| async updateHeader(sessionId: string, patch: Partial<SessionHeader>): Promise<SessionHeader> { |
| await this.ensureReady(); |
| return (await this.metadata.update(sessionId, patch)).header; |
| } |
| |
| async updateHeaderVersioned( |
| sessionId: string, |
| patch: Partial<SessionHeader>, |
| expectedRevision: number, |
| ): Promise<SessionHeaderSnapshot> { |
| await this.ensureReady(); |
| return projectHeaderSnapshot( |
| await this.metadata.update(sessionId, patch, { |
| expectedVersion: expectedRevision, |
| skipNoop: true, |
| }), |
| ); |
| } |
| |
| async updateSessionConfiguration( |
| sessionId: string, |
| input: UpdateSessionConfigurationRequest, |
| ): Promise<SessionHeaderSnapshot> { |
| await this.ensureReady(); |
| return projectHeaderSnapshot(await this.metadata.updateSessionConfiguration(sessionId, input)); |
| } |
| |
| async markSessionReadThroughMessage( |
| sessionId: string, |
| messageId: string, |
| ): Promise<SessionHeaderSnapshot> { |
| for (let attempt = 0; attempt < 3; attempt += 1) { |
| const record = await this.readHeaderRecordSnapshot(sessionId); |
| const messages = await this.readMessagesSnapshot(sessionId); |
| const visibleMessages = messages.filter(isVisibleSessionMessage); |
| const targetIndex = visibleMessages.findIndex((message) => message.id === messageId); |
| if (targetIndex < 0) { |
| throw new SessionReadMarkerMessageNotFoundError(sessionId, messageId); |
| } |
| const currentIndex = |
| record.header.lastReadMessageId === undefined |
| ? -1 |
| : visibleMessages.findIndex((message) => message.id === record.header.lastReadMessageId); |
| const hasUnread = targetIndex < visibleMessages.length - 1; |
| if ( |
| targetIndex < currentIndex || |
| (targetIndex === currentIndex && record.header.hasUnread === hasUnread) |
| ) { |
| return record; |
| } |
| try { |
| return await this.updateHeaderVersioned( |
| sessionId, |
| { lastReadMessageId: messageId, hasUnread }, |
| record.revision, |
| ); |
| } catch (error) { |
| if (!(error instanceof SessionMetadataVersionConflictError) || attempt === 2) throw error; |
| } |
| } |
| throw new Error('Session read marker retry loop did not terminate'); |
| } |
| |
| async probeSessionRemoval(sessionId: string): Promise<ProbeSessionRemovalResult> { |
| await this.ensureReady(); |
| return projectRemovalProbe(await this.metadata.probeRemoval(sessionId)); |
| } |
| |
| async setSessionsLifecycleVersioned( |
| sessions: readonly VersionedSessionIdentity[], |
| state: 'active' | 'archived', |
| ): Promise<SessionHeaderSnapshot[]> { |
| await this.ensureReady(); |
| return (await this.metadata.setLifecycleVersioned(sessions, state)).map(projectHeaderSnapshot); |
| } |
| |
| async removeSessionsVersioned(sessions: readonly VersionedSessionIdentity[]): Promise<string[]> { |
| await this.ensureReady(); |
| return this.metadata.removeVersioned(sessions); |
| } |
| |
| async reconcileOrphanedAgentGraphRetirements(): Promise<string[]> { |
| await this.ensureReady(); |
| return this.metadata.reconcileOrphanedAgentGraphRetirements(); |
| } |
| |
| async listPendingSessionRetirementCleanupIds(sessionId?: string): Promise<string[]> { |
| await this.ensureReady(); |
| return this.metadata.listPendingSessionRetirementCleanupIds(sessionId); |
| } |
| |
| async completeSessionRetirementCleanup(sessionId: string): Promise<void> { |
| await this.ensureReady(); |
| await this.metadata.completeSessionRetirementCleanup(sessionId); |
| } |
| |
| async markSessionReadThrough(sessionId: string, readThroughTs: number): Promise<SessionHeader> { |
| const header = await this.readHeaderSnapshot(sessionId); |
| const messages = await this.readMessagesSnapshot(sessionId); |
| const effectiveLastMessageAt = maxTimestamp( |
| header.lastMessageAt, |
| latestVisibleMessageAt(messages), |
| ); |
| if ( |
| !Number.isFinite(readThroughTs) || |
| !header.hasUnread || |
| (effectiveLastMessageAt !== undefined && effectiveLastMessageAt > readThroughTs) |
| ) { |
| return header; |
| } |
| return this.updateHeader(sessionId, { hasUnread: false }); |
| } |
| |
| async archive(sessionId: string): Promise<void> { |
| const now = Date.now(); |
| await this.updateHeader(sessionId, { |
| isArchived: true, |
| archivedAt: now, |
| status: 'archived', |
| statusUpdatedAt: now, |
| }); |
| } |
| |
| async unarchive(sessionId: string): Promise<void> { |
| await this.updateHeader(sessionId, { |
| isArchived: false, |
| archivedAt: undefined, |
| status: 'active', |
| blockedReason: undefined, |
| statusUpdatedAt: Date.now(), |
| }); |
| } |
| |
| async setFlagged(sessionId: string, isFlagged: boolean): Promise<void> { |
| await this.updateHeader(sessionId, { isFlagged }); |
| } |
| |
| async rename(sessionId: string, name: string): Promise<void> { |
| const normalized = normalizeUserSessionName(name); |
| if (!normalized.ok) throw new Error(normalized.error); |
| await this.updateHeader(sessionId, { |
| name: normalized.value, |
| titleIsManual: true, |
| }); |
| } |
| |
| async setGeneratedTitleIfAbsent(sessionId: string, title: string): Promise<SessionHeader | null> { |
| const normalized = normalizeUserSessionName(title); |
| if (!normalized.ok) return null; |
| const current = await this.readHeaderSnapshot(sessionId); |
| if ( |
| current.titleIsManual || |
| current.name !== DEFAULT_SESSION_NAME || |
| normalized.value === current.name |
| ) { |
| return null; |
| } |
| return this.updateHeader(sessionId, { name: normalized.value }); |
| } |
| |
| async remove(sessionId: string): Promise<void> { |
| await this.ensureReady(); |
| await this.metadata.remove(sessionId); |
| } |
| |
| close(): Promise<void> { |
| this.closePromise ??= this.closeAfterReady(); |
| return this.closePromise; |
| } |
| |
| private async closeAfterReady(): Promise<void> { |
| // Ensure the one-time import has settled before closing the database so |
| // a concurrent close cannot race an in-flight migration. |
| await this.ensureReady(); |
| this.metadata.close(); |
| } |
| |
| private async ensureCatalogProjectionReadable(): Promise<void> { |
| await this.ensureReady(); |
| } |
| } |
| |
| function buildSessionHeader( |
| workspaceRoot: string, |
| input: CreateSessionInput, |
| sessionId: string = randomUUID(), |
| conversationCopy?: SessionConversationCopy, |
| ): SessionHeader { |
| if ( |
| input.projectId !== undefined && |
| input.projectId !== null && |
| (typeof input.projectId !== 'string' || input.projectId.length === 0) |
| ) { |
| throw new Error('Invalid project id'); |
| } |
| const now = Date.now(); |
| assertSafeSessionId(sessionId); |
| const name = |
| input.name === undefined ? DEFAULT_SESSION_NAME : normalizeRequiredSessionName(input.name); |
| const header: SessionHeader = { |
| id: sessionId, |
| workspaceRoot, |
| cwd: input.cwd, |
| ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), |
| createdAt: now, |
| lastUsedAt: now, |
| name, |
| titleIsManual: false, |
| isFlagged: false, |
| labels: input.labels ?? [], |
| isArchived: false, |
| status: input.status ?? 'active', |
| ...(input.blockedReason ? { blockedReason: input.blockedReason } : {}), |
| statusUpdatedAt: now, |
| ...(input.parentSessionId ? { parentSessionId: input.parentSessionId } : {}), |
| ...(input.branchOfTurnId ? { branchOfTurnId: input.branchOfTurnId } : {}), |
| ...(input.subagentParent ? { subagentParent: input.subagentParent } : {}), |
| ...(input.subagentRuntime ? { subagentRuntime: input.subagentRuntime } : {}), |
| ...(input.subagentSpawn ? { subagentSpawn: input.subagentSpawn } : {}), |
| ...(input.subagentWorkspace ? { subagentWorkspace: input.subagentWorkspace } : {}), |
| ...(conversationCopy ? { conversationCopy } : {}), |
| ...(input.revisionRootSessionId ? { revisionRootSessionId: input.revisionRootSessionId } : {}), |
| ...(input.revisionParentSessionId |
| ? { revisionParentSessionId: input.revisionParentSessionId } |
| : {}), |
| ...(input.revisionOfTurnId ? { revisionOfTurnId: input.revisionOfTurnId } : {}), |
| ...(input.revisionIndex !== undefined ? { revisionIndex: input.revisionIndex } : {}), |
| ...(input.revisionState ? { revisionState: input.revisionState } : {}), |
| hasUnread: false, |
| backend: input.backend, |
| llmConnectionSlug: input.llmConnectionSlug, |
| connectionLocked: false, |
| model: input.model ?? 'default', |
| permissionMode: input.permissionMode, |
| collaborationMode: input.collaborationMode ?? 'agent', |
| orchestrationMode: input.orchestrationMode ?? 'default', |
| ...(input.thinkingLevel !== undefined ? { thinkingLevel: input.thinkingLevel } : {}), |
| schemaVersion: 1, |
| }; |
| assertValidSessionLineage(header); |
| return header; |
| } |
| |
| function normalizeRequiredSessionName(name: string): string { |
| const normalized = normalizeUserSessionName(name); |
| if (!normalized.ok) throw new Error(normalized.error); |
| return normalized.value; |
| } |
| |
| /** Validate and normalize a current SessionHeader before canonical persistence. */ |
| export function normalizeSessionHeader( |
| header: SessionHeader, |
| sessionId: string = header.id, |
| ): SessionHeader { |
| const valid = |
| header.id === sessionId && |
| typeof header.workspaceRoot === 'string' && |
| typeof header.cwd === 'string' && |
| (header.projectId === undefined || |
| header.projectId === null || |
| (typeof header.projectId === 'string' && header.projectId.length > 0)) && |
| isFiniteNumber(header.createdAt) && |
| isFiniteNumber(header.lastUsedAt) && |
| (header.lastMessageAt === undefined || isFiniteNumber(header.lastMessageAt)) && |
| typeof header.name === 'string' && |
| typeof header.titleIsManual === 'boolean' && |
| typeof header.isFlagged === 'boolean' && |
| Array.isArray(header.labels) && |
| header.labels.every((label) => typeof label === 'string') && |
| typeof header.isArchived === 'boolean' && |
| (header.archivedAt === undefined || isFiniteNumber(header.archivedAt)) && |
| isSessionStatus(header.status) && |
| (header.blockedReason === undefined || isSessionBlockedReason(header.blockedReason)) && |
| (header.statusUpdatedAt === undefined || isFiniteNumber(header.statusUpdatedAt)) && |
| (header.parentSessionId === undefined || typeof header.parentSessionId === 'string') && |
| (header.branchOfTurnId === undefined || typeof header.branchOfTurnId === 'string') && |
| isValidConversationCopyLineage(header) && |
| isValidRevisionLineage(header) && |
| isValidSubagentSessionLineage(header) && |
| (header.lastReadMessageId === undefined || typeof header.lastReadMessageId === 'string') && |
| typeof header.hasUnread === 'boolean' && |
| isBackendKind(header.backend) && |
| typeof header.llmConnectionSlug === 'string' && |
| typeof header.connectionLocked === 'boolean' && |
| typeof header.model === 'string' && |
| isPermissionMode(header.permissionMode) && |
| isCollaborationMode(header.collaborationMode) && |
| isOrchestrationMode(header.orchestrationMode) && |
| header.schemaVersion === 1; |
| if (!valid) { |
| throw new Error(`Invalid session header for session ${sessionId}: malformed fields`); |
| } |
| const normalizedName = normalizeSessionName(header.name); |
| if (header.blockedReason === undefined) { |
| const { blockedReason: _blockedReason, ...withoutBlockedReason } = header; |
| return { ...withoutBlockedReason, name: normalizedName }; |
| } |
| return { ...header, name: normalizedName }; |
| } |
| |
| function isValidRevisionLineage(header: SessionHeader): boolean { |
| const values = [ |
| header.revisionRootSessionId, |
| header.revisionParentSessionId, |
| header.revisionOfTurnId, |
| header.revisionIndex, |
| header.revisionState, |
| ]; |
| if (values.every((value) => value === undefined)) return true; |
| return ( |
| typeof header.revisionRootSessionId === 'string' && |
| isSafeSessionId(header.revisionRootSessionId) && |
| typeof header.revisionParentSessionId === 'string' && |
| isSafeSessionId(header.revisionParentSessionId) && |
| typeof header.revisionOfTurnId === 'string' && |
| header.revisionOfTurnId.length > 0 && |
| header.revisionOfTurnId.length <= 128 && |
| Number.isSafeInteger(header.revisionIndex) && |
| header.revisionIndex! >= 2 && |
| (header.revisionState === 'preparing' || header.revisionState === 'committed') |
| ); |
| } |
| |
| function assertValidSessionLineage(header: SessionHeader): void { |
| if (!isValidConversationCopyLineage(header)) { |
| throw new Error('Invalid Session conversation-copy lineage'); |
| } |
| if (!isValidRevisionLineage(header)) { |
| throw new Error('Invalid session revision lineage'); |
| } |
| if (!isValidSubagentSessionLineage(header)) { |
| throw new Error('Invalid subagent session lineage'); |
| } |
| } |
| |
| function isValidConversationCopyLineage(header: SessionHeader): boolean { |
| const copy = header.conversationCopy; |
| if (copy === undefined) return true; |
| if ( |
| !isSessionConversationCopy(copy) || |
| !isSafeSessionId(copy.sourceSessionId) || |
| copy.sourceSessionId === header.id || |
| header.subagentParent !== undefined |
| ) { |
| return false; |
| } |
| if (copy.kind === 'branch') { |
| return ( |
| header.parentSessionId === copy.sourceSessionId && |
| header.branchOfTurnId === copy.sourceTurnId && |
| header.revisionRootSessionId === undefined && |
| header.revisionParentSessionId === undefined && |
| header.revisionOfTurnId === undefined && |
| header.revisionIndex === undefined && |
| header.revisionState === undefined |
| ); |
| } |
| return ( |
| header.revisionParentSessionId === copy.sourceSessionId && |
| header.revisionOfTurnId === copy.sourceTurnId |
| ); |
| } |
| |
| function isValidSubagentSessionLineage(header: SessionHeader): boolean { |
| if (header.subagentParent === undefined) { |
| return ( |
| header.subagentRuntime === undefined && |
| header.subagentSpawn === undefined && |
| header.subagentWorkspace === undefined |
| ); |
| } |
| if ( |
| !isSubagentSessionParent(header.subagentParent) || |
| !isSafeSessionId(header.subagentParent.parentSessionId) || |
| header.parentSessionId !== undefined || |
| header.branchOfTurnId !== undefined || |
| header.revisionRootSessionId !== undefined || |
| header.revisionParentSessionId !== undefined || |
| header.revisionOfTurnId !== undefined || |
| header.revisionIndex !== undefined || |
| header.revisionState !== undefined |
| ) { |
| return false; |
| } |
| return ( |
| (header.subagentRuntime === undefined && |
| header.subagentSpawn === undefined && |
| header.subagentWorkspace === undefined) || |
| (isSubagentSessionRuntime(header.subagentRuntime) && |
| isSubagentSessionSpawn(header.subagentSpawn) && |
| (header.subagentWorkspace === undefined || |
| isSubagentWorkspaceBinding(header.subagentWorkspace))) |
| ); |
| } |
| |
| function isBackendKind(value: unknown): value is SessionHeader['backend'] { |
| return value === 'ai-sdk' || value === 'fake' || value === 'pi-agent'; |
| } |
| |
| function isFiniteNumber(value: unknown): value is number { |
| return typeof value === 'number' && Number.isFinite(value); |
| } |
| |
| function assertNoConversationCopyMetadata(input: CreateSessionInput): void { |
| if (Object.prototype.hasOwnProperty.call(input, 'conversationCopy')) { |
| throw new Error('Conversation copy metadata requires createStableSession()'); |
| } |
| } |
| |
| function projectHeaderSnapshot(record: SessionMetadataRecord): SessionHeaderSnapshot { |
| return { |
| header: record.header, |
| revision: record.metadataVersion, |
| committedAt: record.committedAt, |
| }; |
| } |
| |
| function projectRemovalProbe(probe: SessionRemovalProbe): ProbeSessionRemovalResult { |
| return probe.kind === 'present' |
| ? { kind: 'present', record: projectHeaderSnapshot(probe.record) } |
| : probe; |
| } |
| |
| function projectCatalogRevision(state: SessionCatalogRevisionState): `sha256:${string}` { |
| return `sha256:${createHash('sha256') |
| .update(`${state.epoch}:${state.generation}`) |
| .digest('hex')}`; |
| } |
| |
| function projectStableSessionCreateProbe( |
| probe: StableSessionCreateProbe, |
| ): ProbeStableSessionCreateResult { |
| return probe.kind === 'existing' |
| ? { kind: 'existing', record: projectHeaderSnapshot(probe.record) } |
| : probe; |
| } |
| |
| function toSummary(header: SessionHeader, messages: StoredMessage[] = []): SessionSummary { |
| const preview = lastMessagePreviewForMessages(messages); |
| const derivedLastMessageAt = latestVisibleMessageAt(messages); |
| const lastMessageAt = maxTimestamp(header.lastMessageAt, derivedLastMessageAt); |
| return { |
| id: header.id, |
| cwd: header.cwd, |
| ...(header.projectId !== undefined ? { projectId: header.projectId } : {}), |
| name: normalizeSessionName(header.name), |
| isFlagged: header.isFlagged, |
| isArchived: header.isArchived, |
| labels: header.labels, |
| hasUnread: header.hasUnread, |
| lastMessageAt, |
| ...(preview ? { lastMessagePreview: preview } : {}), |
| status: header.status, |
| ...(header.blockedReason ? { blockedReason: header.blockedReason } : {}), |
| ...(header.statusUpdatedAt !== undefined ? { statusUpdatedAt: header.statusUpdatedAt } : {}), |
| ...(header.parentSessionId ? { parentSessionId: header.parentSessionId } : {}), |
| ...(header.branchOfTurnId ? { branchOfTurnId: header.branchOfTurnId } : {}), |
| ...(header.subagentParent ? { subagentParent: header.subagentParent } : {}), |
| ...(header.subagentRuntime |
| ? { subagentRuntime: subagentSessionRuntimeSummary(header.subagentRuntime) } |
| : {}), |
| ...(header.subagentWorkspace ? { subagentWorkspace: header.subagentWorkspace } : {}), |
| ...(header.revisionRootSessionId |
| ? { revisionRootSessionId: header.revisionRootSessionId } |
| : {}), |
| ...(header.revisionParentSessionId |
| ? { revisionParentSessionId: header.revisionParentSessionId } |
| : {}), |
| ...(header.revisionOfTurnId ? { revisionOfTurnId: header.revisionOfTurnId } : {}), |
| ...(header.revisionIndex !== undefined ? { revisionIndex: header.revisionIndex } : {}), |
| ...(header.revisionState ? { revisionState: header.revisionState } : {}), |
| backend: header.backend, |
| llmConnectionSlug: header.llmConnectionSlug, |
| connectionLocked: header.connectionLocked, |
| model: header.model, |
| permissionMode: header.permissionMode, |
| collaborationMode: header.collaborationMode ?? 'agent', |
| orchestrationMode: header.orchestrationMode ?? 'default', |
| ...(header.thinkingLevel !== undefined ? { thinkingLevel: header.thinkingLevel } : {}), |
| }; |
| } |
| |
| function toCatalogSummary( |
| header: SessionHeader, |
| lastMessagePreview: string | undefined, |
| ): SessionSummary { |
| return { |
| ...toSummary(header), |
| ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), |
| }; |
| } |
| |
| export function projectSessionCatalogMessages(messages: readonly StoredMessage[]): { |
| readonly lastMessageAt?: number; |
| readonly lastMessagePreview?: string; |
| } { |
| const lastMessageAt = latestVisibleMessageAt(messages); |
| const lastMessagePreview = lastMessagePreviewForMessages(messages); |
| return { |
| ...(lastMessageAt === undefined ? {} : { lastMessageAt }), |
| ...(lastMessagePreview === undefined ? {} : { lastMessagePreview }), |
| }; |
| } |
| |
| function latestVisibleMessageAt(messages: readonly StoredMessage[]): number | undefined { |
| for (let index = messages.length - 1; index >= 0; index -= 1) { |
| const message = messages[index]!; |
| if (isVisibleSessionMessage(message)) return message.ts; |
| } |
| return undefined; |
| } |
| |
| function isVisibleSessionMessage( |
| message: StoredMessage, |
| ): message is Extract<StoredMessage, { type: 'user' | 'assistant' }> { |
| return message.type === 'user' || message.type === 'assistant'; |
| } |
| |
| function maxTimestamp(left: number | undefined, right: number | undefined): number | undefined { |
| if (left === undefined) return right; |
| if (right === undefined) return left; |
| return Math.max(left, right); |
| } |
| |
| function normalizeSessionName(name: string): string { |
| return name === 'New Session' ? DEFAULT_SESSION_NAME : name; |
| } |
| |
| function lastMessagePreviewForMessages(messages: readonly StoredMessage[]): string | undefined { |
| for (let index = messages.length - 1; index >= 0; index -= 1) { |
| const message = messages[index]!; |
| if (message.type === 'user') { |
| // Prefer the human-facing view when the stored model text is a composed |
| // envelope (e.g. explicit skill invocation). |
| const text = normalizePreviewText(message.displayText ?? message.text); |
| if (text) return truncatePreview(text); |
| if (message.attachments && message.attachments.length > 0) return '附件'; |
| } |
| if (message.type === 'assistant') { |
| const text = normalizePreviewText(message.text); |
| if (text) return truncatePreview(text); |
| } |
| } |
| return undefined; |
| } |
| |
| function normalizePreviewText(text: string): string { |
| return text.replace(/\s+/g, ' ').trim(); |
| } |
| |
| function truncatePreview(text: string, maxLength = 96): string { |
| const chars = Array.from(text); |
| if (chars.length <= maxLength) return text; |
| return `${chars.slice(0, maxLength - 1).join('')}…`; |
| } |
| |
| export function createUserMessage(input: { |
| turnId: string; |
| text: string; |
| displayText?: string; |
| attachments?: UserMessage['attachments']; |
| inlineReferences?: UserMessage['inlineReferences']; |
| }): UserMessage { |
| return { |
| type: 'user', |
| id: randomUUID(), |
| turnId: input.turnId, |
| ts: Date.now(), |
| text: input.text, |
| ...(input.displayText !== undefined ? { displayText: input.displayText } : {}), |
| attachments: input.attachments, |
| ...(input.inlineReferences !== undefined ? { inlineReferences: input.inlineReferences } : {}), |
| }; |
| } |